Compare commits

..

8 Commits

Author SHA1 Message Date
Kevin Jahns
bf4d33dba6 Merge pull request #628 from angelo-v/patch-1
Update README.md
2024-04-12 16:00:14 +02:00
Angelo Veltens
9502a4ae60 Update README.md
fix filename
2024-04-12 15:34:32 +02:00
Kevin Jahns
7769fde19d Merge pull request #580 from gveres/patch-1
Add Evernote as a user of Yjs
2023-11-14 13:43:42 +01:00
Gabor Veres
1c8e6d8280 Add Evernote as a user of Yjs
According to https://evernote.com/blog/future-proofing-evernotes-foundations/ they have moved to Yjs lately
2023-10-06 09:44:30 +02:00
Kevin Jahns
e0b111510b Merge pull request #497 from stefanionita/patch-1
Fix minor typos in README.md
2023-01-24 12:42:57 +01:00
Stefan Ionita
0b769d67ac Update README.md
fixes typo and removes repeated word
2023-01-23 16:14:30 -05:00
Kevin Jahns
e07b0f4100 Merge pull request #211 from relm-us/doc-get-docs
Small improvements to Y.Doc().get(..) documentation
2020-06-16 22:28:24 +02:00
Duane Johnson
78c947273e Small improvements to Y.Doc().get(..) documentation 2020-06-09 18:46:14 -06:00
62 changed files with 2439 additions and 11864 deletions

View File

@@ -1,29 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x, 14.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build --if-present
- run: npm test

View File

@@ -17,13 +17,10 @@
"useCollapsibles": true,
"collapse": true,
"resources": {
"yjs.dev": "Website",
"docs.yjs.dev": "Docs",
"discuss.yjs.dev": "Forum",
"https://gitter.im/Yjs/community": "Chat"
"yjs.dev": "Yjs website"
},
"logo": {
"url": "https://yjs.dev/images/logo/yjs-512x512.png",
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
"width": "162px",
"height": "162px",
"link": "/"
@@ -38,7 +35,7 @@
],
"default": {
"staticFiles": {
"include": []
"include": ["examples/"]
}
}
},
@@ -47,6 +44,7 @@
"encoding": "utf8",
"private": false,
"recurse": true,
"template": "./node_modules/tui-jsdoc-template"
"template": "./node_modules/tui-jsdoc-template",
"tutorials": "./examples"
}
}

View File

@@ -1,179 +0,0 @@
# Yjs Internals
This document roughly explains how Yjs works internally. There is a complete
walkthrough of the Yjs codebase available as a recording:
https://youtu.be/0l5XgnQ6rB4
The Yjs CRDT algorithm is described in the [YATA
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types)
from 2016. For an algorithmic view of how it works, the paper is a reasonable
place to start. There are a handful of small improvements implemented in Yjs
which aren't described in the paper. The most notable is that items have an
`originRight` as well as an `origin` property, which improves performance when
many concurrent inserts happen after the same character.
At its heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
reuse the CRDT resolution algorithm:
- Arrays are easy - they're lists of arbitrary items.
- Text is a list of characters, optionally punctuated by formatting markers and
embeds for rich text support. Several characters can be wrapped in a single
linked list `Item` (this is also known as the compound representation of
CRDTs). More information about this in [this blog
article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/).
- Maps are lists of entries. The last inserted entry for each key is used, and
all other duplicates for each key are flagged as deleted.
Each client is assigned a unique *clientID* property on first insert. This is a
random 53-bit integer (53 bits because that fits in the javascript safe integer
range).
## List items
Each item in a Yjs list is made up of two objects:
- An `Item` (*src/structs/Item.js*). This is used to relate the item to other
adjacent items.
- An object in the `AbstractType` hierarchy (subclasses of
*src/types/AbstractType.js* - eg `YText`). This stores the actual content in
the Yjs document.
The item and type object pair have a 1-1 mapping. The item's `content` field
references the AbstractType object and the AbstractType object's `_item` field
references the item.
Everything inserted in a Yjs document is given a unique ID, formed from a
*ID(clientID, clock)* pair (also known as a [Lamport
Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp)). The clock counts
up from 0 with the first inserted character or item a client makes. This is
similar to automerge's operation IDs, but note that the clock is only
incremented by inserts. Deletes are handled in a very different way (see
below).
If a run of characters is inserted into a document (eg `"abc"`), the clock will
be incremented for each character (eg 3 times here). But Yjs will only add a
single `Item` into the list. This has no effect on the core CRDT algorithm, but
the optimization dramatically decreases the number of javascript objects
created during normal text editing. This optimization only applies if the
characters share the same clientID, they're inserted in order, and all
characters have either been deleted or all characters are not deleted. The item
will be split if the run is interrupted for any reason (eg a character in the
middle of the run is deleted).
When an item is created, it stores a reference to the IDs of the preceeding and
succeeding item. These are stored in the item's `origin` and `originRight`
fields, respectively. These are used when peers concurrently insert at the same
location in a document. Though quite rare in practice, Yjs needs to make sure
the list items always resolve to the same order on all peers. The actual logic
is relatively simple - its only a couple dozen lines of code and it lives in
the `Item#integrate()` method. The YATA paper has much more detail on the this
algorithm.
### Item Storage
The items themselves are stored in two data structures and a cache:
- The items are stored in a tree of doubly-linked lists in *document order*.
Each item has `left` and `right` properties linking to its siblings in the
document. Items also have a `parent` property to reference their parent in the
document tree (null at the root). (And you can access an item's children, if
any, through `item.content`).
- All items are referenced in *insertion order* inside the struct store
(*src/utils/StructStore.js*). This references the list of items inserted by
for each client, in chronological order. This is used to find an item in the
tree with a given ID (using a binary search). It is also used to efficiently
gather the operations a peer is missing during sync (more on this below).
When a local insert happens, Yjs needs to map the insert position in the
document (eg position 1000) to an ID. With just the linked list, this would
require a slow O(n) linear scan of the list. But when editing a document, most
inserts are either at the same position as the last insert, or nearby. To
improve performance, Yjs stores a cache of the 10 most recently looked up
insert positions in the document. This is consulted and updated when a position
is looked up to improve performance in the average case. The cache is updated
using a heuristic that is still changing (currently, it is updated when a new
position significantly diverges from existing markers in the cache). Internally
this is referred to as the skip list / fast search marker.
### Deletions
Deletions in Yjs are treated very differently from insertions. Insertions are
implemented as a sequential operation based CRDT, but deletions are treated as
a simpler state based CRDT.
When an item has been deleted by any peer, at any point in history, it is
flagged as deleted on the item. (Internally Yjs uses the `info` bitfield.) Yjs
does not record metadata about a deletion:
- No data is kept on *when* an item was deleted, or which user deleted it.
- The struct store does not contain deletion records
- The clientID's clock is not incremented
If garbage collection is enabled in Yjs, when an object is deleted its content
is discarded. If a deleted object contains children (eg a field is deleted in
an object), the content is replaced with a `GC` object (*src/structs/GC.js*).
This is a very lightweight structure - it only stores the length of the removed
content.
Yjs has some special logic to share which content in a document has been
deleted:
- When a delete happens, as well as marking the item, the deleted IDs are
listed locally within the transaction. (See below for more information about
transactions.) When a transaction has been committed locally, the set of
deleted items is appended to a transaction's update message.
- A snapshot (a marked point in time in the Yjs history) is specified using
both the set of (clientID, clock) pairs *and* the set of all deleted item
IDs. The deleted set is O(n), but because deletions usually happen in runs,
this data set is usually tiny in practice. (The real world editing trace from
the B4 benchmark document contains 182k inserts and 77k deleted characters. The
deleted set size in a snapshot is only 4.5Kb).
## Transactions
All updates in Yjs happen within a *transaction*. (Defined in
*src/utils/Transaction.js*.)
The transaction collects a set of updates to the Yjs document to be applied on
remote peers atomically. Once a transaction has been committed locally, it
generates a compressed *update message* which is broadcast to synchronized
remote peers to notify them of the local change. The update message contains:
- The set of newly inserted items
- The set of items deleted within the transaction.
## Network protocol
The network protocol is not really a part of Yjs. There are a few relevant
concepts that can be used to create a custom network protocol:
* `update`: The Yjs document can be encoded to an *update* object that can be
parsed to reconstruct the document. Also every change on the document fires
an incremental document updates that allows clients to sync with each other.
The update object is an Uint8Array that efficiently encodes `Item` objects and
the delete set.
* `state vector`: A state vector defines the known state of each user (a set of
tuples `(client, clock)`). This object is also efficiently encoded as a
Uint8Array.
The client can ask a remote client for missing document updates by sending
their state vector (often referred to as *sync step 1*). The remote peer can
compute the missing `Item` objects using the `clocks` of the respective clients
and compute a minimal update message that reflects all missing updates (sync
step 2).
An implementation of the syncing process is in
[y-protocols](https://github.com/yjs/y-protocols).
## Snapshots
A snapshot can be used to restore an old document state. It is a `state vector`
\+ `delete set`. A client can restore an old document state by iterating through
the sequence CRDT and ignoring all Items that have an `id.clock >
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
use the delete set to find out if an item was deleted or not.
It is not recommended to restore an old document state using snapshots,
although that would certainly be possible. Instead, the old state should be
computed by iterating through the newest state and using the additional
information from the state vector.

350
README.md
View File

@@ -20,37 +20,49 @@ suited for even large documents.
* Podcast [**"Yjs Deep Dive into real time collaborative editing solutions":**](https://www.tag1consulting.com/blog/deep-dive-real-time-collaborative-editing-solutions-tagteamtalk-001-0)
* Podcast [**"Google Docs-style editing in Gutenberg with the YJS framework":**](https://publishpress.com/blog/yjs/)
:construction_worker_woman: If you are looking for professional support, please
consider supporting this project via a "support contract" on
[GitHub Sponsors](https://github.com/sponsors/dmonad). I will attend your issues
quicker and we can discuss questions and problems in regular video conferences.
Otherwise you can find help on our community [discussion board](https://discuss.yjs.dev).
:construction_worker_woman: If you are looking for professional (paid) support to
build collaborative or distributed applications ping us at
<yjs@tag1consulting.com>. Otherwise you can find help on our
[discussion board](https://discuss.yjs.dev).
## Sponsorship
## Sponsors
Please contribute to the project financially - especially if your company relies
on Yjs. [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad)
I'm currently looking for sponsors that allow me to be less dependent on
contracting work. These awesome backers already fund further development of
Yjs:
[![Vincent Waller](https://github.com/vwall.png?size=60)](https://github.com/vwall)
[<img src="https://user-images.githubusercontent.com/5553757/83337333-a7bcb380-a2ba-11ea-837b-e404eb35d318.png"
height="60px" />](https://input.com/)
[![Duane Johnson](https://github.com/canadaduane.png?size=60)](https://github.com/canadaduane)
[![Joe Reeve](https://github.com/ISNIT0.png?size=60)](https://github.com/ISNIT0)
[<img src="https://room.sh/img/icons/android-chrome-192x192.png" height="60px" />](https://room.sh/)
[![JourneyApps](https://github.com/journeyapps.png?size=60)](https://github.com/journeyapps)
[![Adam Brunnmeier](https://github.com/adabru.png?size=60)](https://github.com/adabru)
[![Nathanael Anderson](https://github.com/NathanaelA.png?size=60)](https://github.com/NathanaelA)
[![Gremloon](https://github.com/gremloon.png?size=60)](https://github.com/gremloon)
Sponsorship also comes with special perks! [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad)
## Who is using Yjs
* [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star2:
* [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and
community. :star:
* [Relm](http://www.relm.us/) A collaborative gameworld for teamwork and
community. :star2:
* [Input](https://input.com/) A collaborative note taking app. :star2:
* [Room.sh](https://room.sh/) A meeting application with integrated
collaborative drawing, editing, and coding tools. :star:
* [http://coronavirustechhandbook.com/](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. :star:
* [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by
Nimbus Web. :star:
* [Pluxbox RadioManager](https://getradiomanager.com/) A web-based app to
collaboratively organize radio broadcasts. :star:
* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted
collaborative notes app.
* [PRSM](https://prsm.uk/) Collaborative mind-mapping and system visualisation. *[(source)](https://github.com/micrology/prsm)*
* [Alldone](https://alldone.app/) A next-gen project management and
collaboration platform.
* [Living Spec](https://livingspec.com/) A modern way for product teams to collaborate.
* [Slidebeamer](https://slidebeamer.com/) Presentation app.
* [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys.
* [Skiff](https://skiff.org/) Private, decentralized workspace.
Nimbus Web.
* [JoeDocs](https://joedocs.com/) An open collaborative wiki.
* [Pluxbox RadioManager](https://pluxbox.com/) A web-based app to
collaboratively organize radio broadcasts.
* [Cattaz](http://cattaz.io/) A wiki that can run custom applications in the
wiki pages.
* [Evernote](https://evernote.com) A note-taking and task management application.
## Table of Contents
@@ -64,6 +76,8 @@ on Yjs. [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%2
* [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions)
* [Y.UndoManager](#YUndoManager)
* [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
* [License and Author](#License-and-Author)
@@ -77,14 +91,10 @@ are implemented in separate modules.
| Name | Cursors | Binding | Demo |
|---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | ✔ | [y-prosemirror](https://github.com/yjs/y-prosemirror) | [demo](https://demos.yjs.dev/prosemirror/prosemirror.html) |
| [Quill](https://quilljs.com/) | ✔ | [y-quill](https://github.com/yjs/y-quill) | [demo](https://demos.yjs.dev/quill/quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](https://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
| [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) |
| [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) |
| [immer](https://github.com/immerjs/immer) | | [immer-yjs](https://github.com/sep2/immer-yjs) | [demo](https://codesandbox.io/s/immer-yjs-demo-6e0znb) |
| React / Vue / Svelte / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) |
| [ProseMirror](https://prosemirror.net/) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://demos.yjs.dev/prosemirror/prosemirror.html) |
| [Quill](https://quilljs.com/) | ✔ | [y-quill](http://github.com/yjs/y-quill) | [demo](https://demos.yjs.dev/quill/quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
### Providers
@@ -94,7 +104,7 @@ manage all that for you and are the perfect starting point for your
collaborative app.
<dl>
<dt><a href="https://github.com/yjs/y-webrtc">y-webrtc</a></dt>
<dt><a href="http://github.com/yjs/y-webrtc">y-webrtc</a></dt>
<dd>
Propagates document updates peer-to-peer using WebRTC. The peers exchange
signaling data over signaling servers. Publically available signaling servers
@@ -102,39 +112,24 @@ are available. Communication over the signaling servers can be encrypted by
providing a shared secret, keeping the connection information and the shared
document private.
</dd>
<dt><a href="https://github.com/yjs/y-websocket">y-websocket</a></dt>
<dt><a href="http://github.com/yjs/y-websocket">y-websocket</a></dt>
<dd>
A module that contains a simple websocket backend and a websocket client that
connects to that backend. The backend can be extended to persist updates in a
leveldb database.
</dd>
<dt><a href="https://github.com/yjs/y-indexeddb">y-indexeddb</a></dt>
<dt><a href="http://github.com/yjs/y-indexeddb">y-indexeddb</a></dt>
<dd>
Efficiently persists document updates to the browsers indexeddb database.
The document is immediately available and only diffs need to be synced through the
network provider.
</dd>
<dt><a href="https://github.com/marcopolo/y-libp2p">y-libp2p</a></dt>
<dt><a href="http://github.com/yjs/y-dat">y-dat</a></dt>
<dd>
Uses <a href="https://libp2p.io/">libp2p</a> to propagate updates via
<a href="https://github.com/libp2p/specs/tree/master/pubsub/gossipsub">GossipSub</a>.
Also includes a peer-sync mechanism to catch up on missed updates.
</dd>
<dt><a href="https://github.com/yjs/y-dat">y-dat</a></dt>
<dd>
[WIP] Write document updates efficiently to the dat network using
[WIP] Write document updates effinciently to the dat network using
<a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has
an append-only log of CRDT local updates (hypercore). Multifeed manages and sync
hypercores and y-dat listens to changes and applies them to the Yjs document.
</dd>
<dt><a href="https://github.com/yousefED/matrix-crdt">Matrix-CRDT</a></dt>
<dd>
Use <a href="https://www.matrix.org">Matrix</a> as an off-the-shelf backend for
Yjs by using the <a href="https://github.com/yousefED/matrix-crdt">MatrixProvider</a>.
Use Matrix as transport and storage of Yjs updates, so you can focus building
your client app and Matrix can provide powerful features like Authentication,
Authorization, Federation, hosting (self-hosting or SaaS) and even End-to-End
Encryption (E2EE).
</dd>
</dl>
@@ -149,7 +144,7 @@ npm i yjs y-websocket
Start the y-websocket server:
```sh
PORT=1234 node ./node_modules/y-websocket/bin/server.js
PORT=1234 node ./node_modules/y-websocket/bin/server.cjs
```
### Example: Observe types
@@ -204,7 +199,7 @@ const ydoc = new Y.Doc()
// this allows you to instantly get the (cached) documents data
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
indexeddbProvider.whenSynced.then(() => {
idbP.whenSynced.then(() => {
console.log('loaded data from indexed db')
})
@@ -247,38 +242,34 @@ necessary.
</p>
<pre>const yarray = new Y.Array()</pre>
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<dd></dd>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd>
Insert content at <var>index</var>. Note that content is an array of elements.
I.e. <code>array.insert(0, [1])</code> splices the list and inserts 1 at
position 0.
</dd>
<b><code>push(Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>unshift(Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<b><code>unshift(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>slice(start:number, end:number):Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;</code></b>
<dd>Retrieve a range of content</dd>
<b><code>length:number</code></b>
<dd></dd>
<b>
<code>
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
forEach(function(value:object|boolean|Array|string|number|Uint8Array|Y.Type,
index:number, array: Y.Array))
</code>
</b>
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;</code></b>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<dd>Copies the content of this YArray to a new Array.</dd>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number|null&gt;</code></b>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number&gt;</code></b>
<dd>
Copies the content of this YArray to a new Array. It transforms all child types
to JSON using their <code>toJSON</code> method.
@@ -320,13 +311,9 @@ or any of its children.
</p>
<pre><code>const ymap = new Y.Map()</code></pre>
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>size: number</code></b>
<dd>Total number of key/value pairs.</dd>
<b><code>get(key:string):object|boolean|string|number|null|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|boolean|string|number|null|Uint8Array|Y.Type)</code></b>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
<dd></dd>
<b><code>delete(key:string)</code></b>
<dd></dd>
@@ -334,16 +321,12 @@ or any of its children.
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>clear()</code></b>
<dd>Removes all elements from this YMap.</dd>
<b><code>clone():Y.Map</code></b>
<dd>Clone this type into a fresh Yjs type.</dd>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|null|Uint8Array&gt;</code></b>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|Uint8Array&gt;</code></b>
<dd>
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
transforms all child types to JSON using their <code>toJSON</code> method.
</dd>
<b><code>forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
<b><code>forEach(function(value:object|boolean|Array|string|number|Uint8Array|Y.Type,
key:string, map: Y.Map))</code></b>
<dd>
Execute the provided function once for every key-value pair.
@@ -405,8 +388,6 @@ YTextEvents compute changes as deltas.
</p>
<pre>const ytext = new Y.Text()</pre>
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<dd></dd>
<b><code>insert(index:number, content:string, [formattingAttributes:Object&lt;string,string&gt;])</code></b>
<dd>
Insert a string at <var>index</var> and assign formatting attributes to it.
@@ -416,7 +397,7 @@ YTextEvents compute changes as deltas.
<dd></dd>
<b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b>
<dd>Assign formatting attributes to a range in the text</dd>
<b><code>applyDelta(delta: Delta, opts:Object&lt;string,any&gt;)</code></b>
<b><code>applyDelta(delta, opts:Object&lt;string,any&gt;)</code></b>
<dd>
See <a href="https://quilljs.com/docs/delta/">Quill Delta</a>
Can set options for preventing remove ending newLines, default is true.
@@ -465,22 +446,14 @@ or any of its children.
</p>
<pre><code>const yxml = new Y.XmlFragment()</code></pre>
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<dd></dd>
<b><code>firstChild:Y.XmlElement|Y.XmlText|null</code></b>
<dd></dd>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>slice(start:number, end:number):Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Retrieve a range of content</dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>clone():Y.XmlFragment</code></b>
<dd>Clone this type into a fresh Yjs type.</dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():DocumentFragment</code></b>
@@ -489,8 +462,6 @@ or any of its children.
<dd>Get the XML serialization of all descendants.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code>.</dd>
<b><code>createTreeWalker(filter: function(AbstractType&lt;any&gt;):boolean):Iterable</code></b>
<dd>Create an Iterable that walks through the children.</dd>
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
@@ -526,14 +497,6 @@ content and be actually XML compliant.
</p>
<pre><code>const yxml = new Y.XmlElement()</code></pre>
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<dd></dd>
<b><code>firstChild:Y.XmlElement|Y.XmlText|null</code></b>
<dd></dd>
<b><code>nextSibling:Y.XmlElement|Y.XmlText|null</code></b>
<dd></dd>
<b><code>prevSibling:Y.XmlElement|Y.XmlText|null</code></b>
<dd></dd>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
@@ -548,14 +511,8 @@ content and be actually XML compliant.
<dd></dd>
<b><code>getAttribute(attributeName:string):string</code></b>
<dd></dd>
<b><code>getAttributes():Object&lt;string,string&gt;</code></b>
<b><code>getAttributes(attributeName:string):Object&lt;string,string&gt;</code></b>
<dd></dd>
<b><code>get(i:number):Y.XmlElement|Y.XmlText</code></b>
<dd>Retrieve the i-th element.</dd>
<b><code>slice(start:number, end:number):Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Retrieve a range of content</dd>
<b><code>clone():Y.XmlElement</code></b>
<dd>Clone this type into a fresh Yjs type.</dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():Element</code></b>
@@ -614,13 +571,6 @@ calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code>
triggers a single change event. <br>You can specify an optional <code>origin</code>
parameter that is stored on <code>transaction.origin</code> and
<code>on('update', (update, origin) => ..)</code>.
</dd>
<b><code>toJSON():any</code></b>
<dd>
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
<code>ydoc.getType(..)</code>).
</dd>
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
<dd>Define a shared type.</dd>
@@ -648,13 +598,6 @@ peers. You can apply document updates in any order and multiple times.
<dd>Emitted before each transaction.</dd>
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
<dd>Emitted after each transaction.</dd>
<b><code>on('beforeAllTransactions', function(Y.Doc):void)</code></b>
<dd>
Transactions can be nested (e.g. when an event within a transaction calls another
transaction). Emitted before the first transaction.
</dd>
<b><code>on('afterAllTransactions', function(Y.Doc, Array&lt;Y.Transaction&gt;):void)</code></b>
<dd>Emitted after the last transaction is cleaned up.</dd>
</dl>
### Document Updates
@@ -713,41 +656,6 @@ Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
```
#### Example: Syncing clients without loading the Y.Doc
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.
```js
// 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])
```
#### Using V2 update format
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`.
#### Update API
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>
@@ -764,48 +672,21 @@ differences to the update message.
</dd>
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
<b><code>Y.mergeUpdates(Array&lt;Uint8Array&gt;)</code></b>
<dd>
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.
</dd>
<b><code>Y.encodeStateVectorFromUpdate(Uint8Array): Uint8Array</code></b>
<dd>
Computes the state vector from a document update and encodes it into an Uint8Array.
</dd>
<b><code>Y.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array</code></b>
<dd>
Encode the missing differences to another update message. This function works
similarly to <code>Y.encodeStateAsUpdate(ydoc, stateVector)</code> but works
on updates instead.
</dd>
<b><code>convertUpdateFormatV1ToV2</code></b>
<dd>
Convert V1 update format to the V2 update format.
</dd>
<b><code>convertUpdateFormatV2ToV1</code></b>
<dd>
Convert V2 update format to the V1 update format.
</dd>
</dl>
### Relative Positions
When working with collaborative documents, we often need to work with positions.
Positions may represent cursor locations, selection ranges, or even assign a
comment to a range of text. Normal index-positions (expressed as integers) are
not convenient to use because the index-range is invalidated as soon as a remote
change manipulates the document. Relative positions give you a powerful API to
express positions.
> This API is not stable yet
A relative position is fixated to an element in the shared document and is not
affected by remote changes. I.e. given the document `"a|c"`, the relative
position is attached to `c`. When a remote user modifies the document by
inserting a character before the cursor, the cursor will stay attached to the
character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the relative position is
set to the end of the document, it will stay attached to the end of the
document.
This feature is intended for managing selections / cursors. When working with
other users that manipulate the shared document, you can't trust that an index
position (an integer) will stay at the intended location. A *relative position*
is fixated to an element in the shared document and is not affected by remote
changes. I.e. given the document `"a|c"`, the relative position is attached to
`c`. When a remote user modifies the document by inserting a character before
the cursor, the cursor will stay attached to the character `c`. `insert(1,
'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the
document, it will stay attached to the end of the document.
#### Example: Transform to RelativePosition and back
@@ -841,35 +722,14 @@ pos.index === 2 // => true
```
<dl>
<b><code>
Y.createRelativePositionFromTypeIndex(type:Uint8Array|Y.Type, index: number
[, assoc=0])
</code></b>
<dd>
Create a relative position fixated to the i-th element in any sequence-like
shared type (if <code>assoc >= 0</code>). By default, the position associates
with the character that comes after the specified index position. If
<code>assoc < 0</code>, then the relative position associates with the character
before the specified index position.
</dd>
<b><code>
Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc):
{ type: Y.AbstractType, index: number, assoc: number } | null
</code></b>
<dd>
Create an absolute position from a relative position. If the relative position
cannot be referenced, or the type is deleted, then the result is null.
</dd>
<b><code>
Y.encodeRelativePosition(RelativePosition):Uint8Array
</code></b>
<dd>
Encode a relative position to an Uint8Array. Binary data is the preferred
encoding format for document updates. If you prefer JSON encoding, you can
simply JSON.stringify / JSON.parse the relative position instead.
</dd>
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
<dd></dd>
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
<dd></dd>
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
<dd></dd>
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
<dd>Decode a binary-encoded relative position to a RelativePositon object.</dd>
<dd></dd>
</dl>
### Y.UndoManager
@@ -910,16 +770,6 @@ undo- or the redo-stack.
</dd>
<b>
<code>
on('stack-item-updated', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when an existing <code>StackItem</code> is updated.
This happens when two changes happen within a "captureInterval".
</dd>
<b>
<code>
on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
@@ -928,14 +778,6 @@ on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
Register an event that is called when a <code>StackItem</code> is popped from
the undo- or the redo-stack.
</dd>
<b>
<code>
on('stack-cleared', { undoStackCleared: boolean, redoStackCleared: boolean })
</code>
</b>
<dd>
Register an event that is called when the undo- and/or the redo-stack is cleared.
</dd>
</dl>
#### Example: Stop Capturing
@@ -1024,11 +866,29 @@ undoManager.on('stack-item-popped', event => {
})
```
## Miscellaneous
### Typescript Declarations
Yjs has type descriptions. But until [this
ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is
how you can make use of Yjs type declarations.
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
},
"maxNodeModuleJsDepth": 5
}
```
## Yjs CRDT Algorithm
*Conflict-free replicated data types* (CRDT) for collaborative editing are an
alternative approach to *operational transformation* (OT). A very simple
differentiation between the two approaches is that OT attempts to transform
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
@@ -1040,14 +900,11 @@ do not require a central source of truth.
Yjs implements a modified version of the algorithm described in [this
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types).
This [article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/)
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](./INTERNALS.md) and in
[this walkthrough of the Yjs codebase](https://youtu.be/0l5XgnQ6rB4).
I will eventually publish a paper that describes why this approach works so well
in practice. Note: Since operations make up the document structure, we prefer
the term *struct* now.
CRDTs that suitable for shared text editing suffer from the fact that they only grow
CRDTs 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
@@ -1061,7 +918,7 @@ order of the structs anymore (e.g. if the parent was deleted).
**Examples:**
1. 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
single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['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'}]`.
@@ -1100,6 +957,5 @@ Yjs and all related projects are [**MIT licensed**](./LICENSE).
Yjs is based on my research as a student at the [RWTH
i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
Fund this project by donating on [GitHub Sponsors](https://github.com/sponsors/dmonad)
or hiring [me](https://github.com/dmonad) as a contractor for your collaborative
app.
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or
hiring [me](https://github.com/dmonad) for professional support.

6425
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,36 @@
{
"name": "yjs",
"version": "13.5.36",
"version": "13.2.0",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"type": "module",
"sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"scripts": {
"test": "npm run dist && node ./dist/tests.cjs --repetition-time 50",
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repetition-time 10000",
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
"dist": "rm -rf dist && rollup -c && tsc",
"watch": "rollup -wc",
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && http-server ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repetition-time 1000 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000",
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
},
"exports": {
".": {
"import": "./dist/yjs.mjs",
"require": "./dist/yjs.cjs"
},
"./src/index.js": "./src/index.js",
"./tests/testHelper.js": "./tests/testHelper.js",
"./testHelper": "./dist/testHelper.mjs",
"./package.json": "./package.json"
},
"files": [
"dist/yjs.*",
"dist/src",
"src",
"tests/testHelper.js",
"dist/testHelper.mjs",
"sponsor-y.js"
"dist/*",
"src/*",
"tests/*",
"docs/*"
],
"dictionaries": {
"doc": "docs",
"test": "tests"
},
"standard": {
@@ -60,8 +48,7 @@
"Yjs",
"CRDT",
"offline",
"offline-first",
"shared-editing",
"shared editing",
"concurrency",
"collaboration"
],
@@ -71,21 +58,22 @@
"bugs": {
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "https://docs.yjs.dev",
"homepage": "https://yjs.dev",
"dependencies": {
"lib0": "^0.2.49"
"lib0": "^0.2.27"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.2.1",
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.1.3",
"concurrently": "^3.6.1",
"http-server": "^0.12.3",
"jsdoc": "^3.6.7",
"markdownlint-cli": "^0.23.2",
"rollup": "^2.60.0",
"standard": "^16.0.4",
"jsdoc": "^3.6.4",
"markdownlint-cli": "^0.23.1",
"rollup": "^1.32.1",
"rollup-cli": "^1.0.9",
"standard": "^14.3.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^4.4.4",
"y-protocols": "^1.0.5"
"typescript": "^3.9.3",
"y-protocols": "^0.2.3"
}
}

View File

@@ -45,7 +45,7 @@ export default [{
sourcemap: true,
paths: path => {
if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5)}.cjs`
return `lib0/dist/${path.slice(5, -3)}.cjs`
}
return path
}
@@ -60,23 +60,6 @@ export default [{
sourcemap: true
},
external: id => /^lib0\//.test(id)
}, {
input: './tests/testHelper.js',
output: {
name: 'Y',
file: 'dist/testHelper.mjs',
format: 'esm',
sourcemap: true
},
external: id => /^lib0\//.test(id) || id === 'yjs',
plugins: [{
resolveId (importee) {
if (importee === '../src/index.js') {
return 'yjs'
}
return null
}
}]
}, {
input: './tests/index.js',
output: {

View File

@@ -1,4 +1,3 @@
/** eslint-env browser */
export {
Doc,
@@ -13,7 +12,6 @@ export {
YXmlEvent,
YMapEvent,
YArrayEvent,
YTextEvent,
YEvent,
Item,
AbstractStruct,
@@ -27,13 +25,14 @@ export {
ContentString,
ContentType,
AbstractType,
RelativePosition,
getTypeChildren,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
AbsolutePosition,
RelativePosition,
writeRelativePosition,
readRelativePosition,
ID,
createID,
compareIDs,
@@ -42,76 +41,23 @@ export {
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
cleanupYTextFormatting,
snapshot,
emptySnapshot,
findRootTypeKey,
findIndexSS,
getItem,
typeListToArraySnapshot,
typeMapGetSnapshot,
createDocFromSnapshot,
iterateDeletedStructs,
applyUpdate,
applyUpdateV2,
readUpdate,
readUpdateV2,
encodeStateAsUpdate,
encodeStateAsUpdateV2,
encodeStateVector,
UndoManager,
decodeSnapshot,
encodeSnapshot,
decodeSnapshotV2,
encodeSnapshotV2,
decodeStateVector,
logUpdate,
logUpdateV2,
decodeUpdate,
decodeUpdateV2,
relativePositionToJSON,
isDeleted,
isParentOf,
equalSnapshots,
PermanentUserData, // @TODO experimental
tryGc,
transact,
AbstractConnector,
logType,
mergeUpdates,
mergeUpdatesV2,
parseUpdateMeta,
parseUpdateMetaV2,
encodeStateVectorFromUpdate,
encodeStateVectorFromUpdateV2,
encodeRelativePosition,
decodeRelativePosition,
diffUpdate,
diffUpdateV2,
convertUpdateFormatV1ToV2,
convertUpdateFormatV2ToV1
transact
} from './internals.js'
const glo = /** @type {any} */ (typeof window !== 'undefined'
? window
// @ts-ignore
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS$ __'
if (glo[importIdentifier] === true) {
/**
* Dear reader of this warning message. Please take this seriously.
*
* If you see this message, please make sure that you only import one version of Yjs. In many cases,
* your package manager installs two versions of Yjs that are used by different packages within your project.
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
* and others use the EcmaScript version of Yjs.
*
* This often leads to issues that are hard to debug. We often need to perform constructor checks,
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
* do the constructor checks anymore - which might break the CRDT algorithm.
*/
console.warn('Yjs was already imported. Importing different versions of Yjs often leads to issues.')
}
glo[importIdentifier] = true

View File

@@ -1,21 +1,16 @@
export * from './utils/AbstractConnector.js'
export * from './utils/DeleteSet.js'
export * from './utils/Doc.js'
export * from './utils/UpdateDecoder.js'
export * from './utils/UpdateEncoder.js'
export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/logging.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js'
export * from './utils/StructStore.js'
export * from './utils/Transaction.js'
export * from './utils/UndoManager.js'
export * from './utils/updates.js'
export * from './utils/YEvent.js'
export * from './types/AbstractType.js'
@@ -32,7 +27,6 @@ export * from './structs/AbstractStruct.js'
export * from './structs/GC.js'
export * from './structs/ContentBinary.js'
export * from './structs/ContentDeleted.js'
export * from './structs/ContentDoc.js'
export * from './structs/ContentEmbed.js'
export * from './structs/ContentFormat.js'
export * from './structs/ContentJSON.js'
@@ -40,4 +34,3 @@ export * from './structs/ContentAny.js'
export * from './structs/ContentString.js'
export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './structs/Skip.js'

View File

@@ -1,9 +1,10 @@
import {
UpdateEncoderV1, UpdateEncoderV2, ID, Transaction // eslint-disable-line
StructStore, ID, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as error from 'lib0/error.js'
export class AbstractStruct {
/**
@@ -34,7 +35,7 @@ export class AbstractStruct {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset
* @param {number} encodingRef
*/

View File

@@ -1,7 +1,10 @@
import {
UpdateEncoderV1, UpdateEncoderV2, UpdateDecoderV1, UpdateDecoderV2, Transaction, Item, StructStore // eslint-disable-line
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class ContentAny {
/**
* @param {Array<any>} arr
@@ -74,15 +77,15 @@ export class ContentAny {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoder.writeLen(len - offset)
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoder.writeAny(c)
encoding.writeAny(encoder, c)
}
}
@@ -95,14 +98,14 @@ export class ContentAny {
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentAny}
*/
export const readContentAny = decoder => {
const len = decoder.readLen()
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
cs.push(decoder.readAny())
cs.push(decoding.readAny(decoder))
}
return new ContentAny(cs)
}

View File

@@ -1,8 +1,11 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as buffer from 'lib0/buffer.js'
import * as error from 'lib0/error.js'
export class ContentBinary {
/**
@@ -70,11 +73,11 @@ export class ContentBinary {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeBuf(this.content)
encoding.writeVarUint8Array(encoder, this.content)
}
/**
@@ -86,7 +89,7 @@ export class ContentBinary {
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @param {decoding.Decoder} decoder
* @return {ContentBinary}
*/
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())
export const readContentBinary = decoder => new ContentBinary(buffer.copyUint8Array(decoding.readVarUint8Array(decoder)))

View File

@@ -1,9 +1,12 @@
import {
addToDeleteSet,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class ContentDeleted {
/**
* @param {number} len
@@ -64,7 +67,7 @@ export class ContentDeleted {
* @param {Item} item
*/
integrate (transaction, item) {
addToDeleteSet(transaction.deleteSet, item.id.client, item.id.clock, this.len)
addToDeleteSet(transaction.deleteSet, item.id, this.len)
item.markDeleted()
}
@@ -77,11 +80,11 @@ export class ContentDeleted {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeLen(this.len - offset)
encoding.writeVarUint(encoder, this.len - offset)
}
/**
@@ -95,7 +98,7 @@ export class ContentDeleted {
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @param {decoding.Decoder} decoder
* @return {ContentDeleted}
*/
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())
export const readContentDeleted = decoder => new ContentDeleted(decoding.readVarUint(decoder))

View File

@@ -1,141 +0,0 @@
import {
Doc, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @param {string} guid
* @param {Object<string, any>} opts
*/
const createDocFromOpts = (guid, opts) => new Doc({ guid, ...opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false })
/**
* @private
*/
export class ContentDoc {
/**
* @param {Doc} doc
*/
constructor (doc) {
if (doc._item) {
console.error('This document was already integrated as a sub-document. You should create a second instance instead with the same guid.')
}
/**
* @type {Doc}
*/
this.doc = doc
/**
* @type {any}
*/
const opts = {}
this.opts = opts
if (!doc.gc) {
opts.gc = false
}
if (doc.autoLoad) {
opts.autoLoad = true
}
if (doc.meta !== null) {
opts.meta = doc.meta
}
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.doc]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentDoc}
*/
copy () {
return new ContentDoc(createDocFromOpts(this.doc.guid, this.opts))
}
/**
* @param {number} offset
* @return {ContentDoc}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentDoc} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
// this needs to be reflected in doc.destroy as well
this.doc._item = item
transaction.subdocsAdded.add(this.doc)
if (this.doc.shouldLoad) {
transaction.subdocsLoaded.add(this.doc)
}
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
if (transaction.subdocsAdded.has(this.doc)) {
transaction.subdocsAdded.delete(this.doc)
} else {
transaction.subdocsRemoved.add(this.doc)
}
}
/**
* @param {StructStore} store
*/
gc (store) { }
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeString(this.doc.guid)
encoder.writeAny(this.opts)
}
/**
* @return {number}
*/
getRef () {
return 9
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentDoc}
*/
export const readContentDoc = decoder => new ContentDoc(createDocFromOpts(decoder.readString(), decoder.readAny()))

View File

@@ -1,9 +1,11 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @private
@@ -74,11 +76,11 @@ export class ContentEmbed {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeJSON(this.embed)
encoding.writeVarString(encoder, JSON.stringify(this.embed))
}
/**
@@ -92,7 +94,7 @@ export class ContentEmbed {
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentEmbed}
*/
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())
export const readContentEmbed = decoder => new ContentEmbed(JSON.parse(decoding.readVarString(decoder)))

View File

@@ -1,9 +1,11 @@
import {
AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Item, StructStore, Transaction // eslint-disable-line
Item, StructStore, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @private
@@ -66,11 +68,7 @@ export class ContentFormat {
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
// @todo searchmarker are currently unsupported for rich text documents
/** @type {AbstractType<any>} */ (item.parent)._searchMarker = null
}
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
@@ -80,12 +78,12 @@ export class ContentFormat {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeKey(this.key)
encoder.writeJSON(this.value)
encoding.writeVarString(encoder, this.key)
encoding.writeVarString(encoder, JSON.stringify(this.value))
}
/**
@@ -97,7 +95,7 @@ export class ContentFormat {
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentFormat}
*/
export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON())
export const readContentFormat = decoder => new ContentFormat(decoding.readVarString(decoder), JSON.parse(decoding.readVarString(decoder)))

View File

@@ -1,7 +1,10 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
@@ -77,15 +80,15 @@ export class ContentJSON {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoder.writeLen(len - offset)
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoder.writeString(c === undefined ? 'undefined' : JSON.stringify(c))
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
}
}
@@ -100,14 +103,14 @@ export class ContentJSON {
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentJSON}
*/
export const readContentJSON = decoder => {
const len = decoder.readLen()
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
const c = decoder.readString()
const c = decoding.readVarString(decoder)
if (c === 'undefined') {
cs.push(undefined)
} else {

View File

@@ -1,7 +1,10 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
@@ -51,17 +54,6 @@ export class ContentString {
splice (offset) {
const right = new ContentString(this.str.slice(offset))
this.str = this.str.slice(0, offset)
// Prevent encoding invalid documents because of splitting of surrogate pairs: https://github.com/yjs/yjs/issues/248
const firstCharCode = this.str.charCodeAt(offset - 1)
if (firstCharCode >= 0xD800 && firstCharCode <= 0xDBFF) {
// Last character of the left split is the start of a surrogate utf16/ucs2 pair.
// We don't support splitting of surrogate pairs because this may lead to invalid documents.
// Replace the invalid character with a unicode replacement character (<28> / U+FFFD)
this.str = this.str.slice(0, offset - 1) + '<27>'
// replace right as well
right.str = '<27>' + right.str.slice(1)
}
return right
}
@@ -88,11 +80,11 @@ export class ContentString {
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeString(offset === 0 ? this.str : this.str.slice(offset))
encoding.writeVarString(encoder, offset === 0 ? this.str : this.str.slice(offset))
}
/**
@@ -106,7 +98,7 @@ export class ContentString {
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentString}
*/
export const readContentString = decoder => new ContentString(decoder.readString())
export const readContentString = decoder => new ContentString(decoding.readVarString(decoder))

View File

@@ -7,13 +7,15 @@ import {
readYXmlFragment,
readYXmlHook,
readYXmlText,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
ID, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractType<any>>}
* @type {Array<function(decoding.Decoder):AbstractType<any>>}
* @private
*/
export const typeRefs = [
@@ -39,7 +41,7 @@ export const YXmlTextRefID = 6
*/
export class ContentType {
/**
* @param {AbstractType<any>} type
* @param {AbstractType<YEvent>} type
*/
constructor (type) {
/**
@@ -109,7 +111,7 @@ export class ContentType {
if (!item.deleted) {
item.delete(transaction)
} else {
// This will be gc'd later and we want to merge it if possible
// Whis will be gc'd later and we want to merge it if possible
// We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
@@ -148,7 +150,7 @@ export class ContentType {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -166,7 +168,7 @@ export class ContentType {
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {ContentType}
*/
export const readContentType = decoder => new ContentType(typeRefs[decoder.readTypeRef()](decoder))
export const readContentType = decoder => new ContentType(typeRefs[decoding.readVarUint(decoder)](decoder))

View File

@@ -2,9 +2,11 @@
import {
AbstractStruct,
addStruct,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
export const structGCRefNumber = 0
/**
@@ -22,9 +24,6 @@ export class GC extends AbstractStruct {
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
@@ -42,12 +41,12 @@ export class GC extends AbstractStruct {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeInfo(structGCRefNumber)
encoder.writeLen(this.length - offset)
encoding.writeUint8(encoder, structGCRefNumber)
encoding.writeVarUint(encoder, this.length - offset)
}
/**

View File

@@ -1,5 +1,7 @@
import {
readID,
writeID,
GC,
getState,
AbstractStruct,
@@ -17,17 +19,19 @@ import {
readContentAny,
readContentString,
readContentEmbed,
readContentDoc,
createID,
readContentFormat,
readContentType,
addChangedTypeToTransaction,
isDeleted,
DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
Doc, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as binary from 'lib0/binary'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
* @todo This should return several items
@@ -126,14 +130,12 @@ export const splitItem = (transaction, leftItem, diff) => {
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
* @param {DeleteSet} itemsToDelete
* @param {boolean} ignoreRemoteMapChanges
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges) => {
export const redoItem = (transaction, item, redoitems) => {
const doc = transaction.doc
const store = doc.store
const ownClientID = doc.clientID
@@ -145,27 +147,42 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
/**
* @type {Item|null}
*/
let left = null
let left
/**
* @type {Item|null}
*/
let right
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true) {
// try to undo parent if it will be undone anyway
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges) === null)) {
return null
}
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
}
const parentType = parentItem === null ? /** @type {AbstractType<any>} */ (item.parent) : /** @type {ContentType} */ (parentItem.content).type
if (item.parentSub === null) {
// Is an array item. Insert at the old position
left = item.left
right = item
} else {
// Is a map item. Insert as current value
left = item
while (left.right !== null) {
left = left.right
if (left.id.client !== ownClientID) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
}
if (left.right !== null) {
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
}
right = null
}
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) {
return null
}
}
if (parentItem !== null && parentItem.redone !== null) {
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
// find next cloned_redo items
while (left !== null) {
/**
@@ -197,33 +214,6 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
}
right = right.right
}
} else {
right = null
if (item.right && !ignoreRemoteMapChanges) {
left = item
// Iterate right while right is in itemsToDelete
// If it is intended to delete right while item is redone, we can expect that item should replace right.
while (left !== null && left.right !== null && isDeleted(itemsToDelete, left.right.id)) {
left = left.right
}
// follow redone
// trace redone until parent matches
while (left !== null && left.redone !== null) {
left = getItemCleanStart(transaction, left.redone)
}
// check wether we were allowed to follow right (indicating that originally this op was replaced by another item)
if (left === null || /** @type {AbstractType<any>} */ (left.parent)._item !== parentItem) {
// invalid parent; should never happen
return null
}
if (left && left.right !== null) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
} else {
left = parentType._map.get(item.parentSub) || null
}
}
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)
@@ -231,7 +221,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
nextId,
left, left && left.lastId,
right, right && right.id,
parentType,
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
item.parentSub,
item.content.copy()
)
@@ -290,7 +280,7 @@ export class Item extends AbstractStruct {
*/
this.parentSub = parentSub
/**
* If this type's effect is redone this type refers to the type that undid
* If this type's effect is reundone this type refers to the type that undid
* this operation.
* @type {ID | null}
*/
@@ -299,31 +289,9 @@ export class Item extends AbstractStruct {
* @type {AbstractContent}
*/
this.content = content
/**
* bit1: keep
* bit2: countable
* bit3: deleted
* bit4: mark - mark node as fast-search-marker
* @type {number} byte
*/
this.info = this.content.isCountable() ? binary.BIT2 : 0
}
/**
* This is used to mark the item as an indexed fast-search marker
*
* @type {boolean}
*/
set marker (isMarked) {
if (((this.info & binary.BIT4) > 0) !== isMarked) {
this.info ^= binary.BIT4
}
}
get marker () {
return (this.info & binary.BIT4) > 0
}
/**
* If true, do not garbage collect this Item.
*/
@@ -387,9 +355,6 @@ export class Item extends AbstractStruct {
this.right = getItemCleanStart(transaction, this.rightOrigin)
this.rightOrigin = this.right.id
}
if ((this.left && this.left.constructor === GC) || (this.right && this.right.constructor === GC)) {
this.parent = null
}
// only set parent if this shouldn't be garbage collected
if (!this.parent) {
if (this.left && this.left.constructor === Item) {
@@ -467,14 +432,10 @@ export class Item extends AbstractStruct {
if (o.id.client < this.id.client) {
left = o
conflictingItems.clear()
} else if (compareIDs(this.rightOrigin, o.rightOrigin)) {
// this and o are conflicting and point to the same integration points. The id decides which item comes first.
// Since this is to the left of o, we can break here
break
} // else, o might be integrated before an item that this conflicts with. If so, we will find it in the next iterations
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) { // use getItem instead of getItemCleanEnd because we don't want / need to split items.
}
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) {
// case 2
if (!conflictingItems.has(getItem(transaction.doc.store, o.origin))) {
if (o.origin === null || !conflictingItems.has(getItem(transaction.doc.store, o.origin))) {
left = o
conflictingItems.clear()
}
@@ -521,7 +482,7 @@ export class Item extends AbstractStruct {
this.content.integrate(transaction, this)
// add parent to transaction.changed
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub)
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.right !== null && this.parentSub !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction)
}
@@ -569,7 +530,6 @@ export class Item extends AbstractStruct {
*/
mergeWith (right) {
if (
this.constructor === right.constructor &&
compareIDs(right.origin, this.lastId) &&
this.right === right &&
compareIDs(this.rightOrigin, right.rightOrigin) &&
@@ -581,19 +541,6 @@ export class Item extends AbstractStruct {
this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content)
) {
const searchMarker = /** @type {AbstractType<any>} */ (this.parent)._searchMarker
if (searchMarker) {
searchMarker.forEach(marker => {
if (marker.p === right) {
// right is going to be "forgotten" so we need to update the marker
marker.p = this
// adjust marker index
if (!this.deleted && this.countable) {
marker.index -= this.length
}
}
})
}
if (right.keep) {
this.keep = true
}
@@ -620,8 +567,8 @@ export class Item extends AbstractStruct {
parent._length -= this.length
}
this.markDeleted()
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
addToDeleteSet(transaction.deleteSet, this.id, this.length)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
this.content.delete(transaction)
}
}
@@ -648,7 +595,7 @@ export class Item extends AbstractStruct {
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset
*/
write (encoder, offset) {
@@ -659,38 +606,28 @@ export class Item extends AbstractStruct {
(origin === null ? 0 : binary.BIT8) | // origin is defined
(rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined
(parentSub === null ? 0 : binary.BIT6) // parentSub is non-null
encoder.writeInfo(info)
encoding.writeUint8(encoder, info)
if (origin !== null) {
encoder.writeLeftID(origin)
writeID(encoder, origin)
}
if (rightOrigin !== null) {
encoder.writeRightID(rightOrigin)
writeID(encoder, rightOrigin)
}
if (origin === null && rightOrigin === null) {
const parent = /** @type {AbstractType<any>} */ (this.parent)
if (parent._item !== undefined) {
const parentItem = parent._item
if (parentItem === null) {
// parent type on y._map
// find the correct key
const ykey = findRootTypeKey(parent)
encoder.writeParentInfo(true) // write parentYKey
encoder.writeString(ykey)
} else {
encoder.writeParentInfo(false) // write parent id
encoder.writeLeftID(parentItem.id)
}
} else if (parent.constructor === String) { // this edge case was added by differential updates
encoder.writeParentInfo(true) // write parentYKey
encoder.writeString(parent)
} else if (parent.constructor === ID) {
encoder.writeParentInfo(false) // write parent id
encoder.writeLeftID(parent)
const parentItem = parent._item
if (parentItem === null) {
// parent type on y._map
// find the correct key
const ykey = findRootTypeKey(parent)
encoding.writeVarUint(encoder, 1) // write parentYKey
encoding.writeVarString(encoder, ykey)
} else {
error.unexpectedCase()
encoding.writeVarUint(encoder, 0) // write parent id
writeID(encoder, parentItem.id)
}
if (parentSub !== null) {
encoder.writeString(parentSub)
encoding.writeVarString(encoder, parentSub)
}
}
this.content.write(encoder, offset)
@@ -698,28 +635,26 @@ export class Item extends AbstractStruct {
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @param {number} info
*/
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
/**
* A lookup map for reading Item content.
*
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
* @type {Array<function(decoding.Decoder):AbstractContent>}
*/
export const contentRefs = [
() => { error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted, // 1
readContentJSON, // 2
readContentBinary, // 3
readContentString, // 4
readContentEmbed, // 5
readContentFormat, // 6
readContentType, // 7
readContentAny, // 8
readContentDoc, // 9
() => { error.unexpectedCase() } // 10 - Skip is not ItemContent
() => { throw error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted,
readContentJSON,
readContentBinary,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
readContentAny
]
/**
@@ -799,7 +734,7 @@ export class AbstractContent {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -813,3 +748,38 @@ export class AbstractContent {
throw error.methodUnimplemented()
}
}
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
* @param {Doc} doc
*/
export const readItem = (decoder, id, info, doc) => {
/**
* The item that was originally to the left of this item.
* @type {ID | null}
*/
const origin = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
/**
* The item that was originally to the right of this item.
* @type {ID | null}
*/
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
/**
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
* and we read the next string as parentYKey.
* It indicates how we store/retrieve parent from `y.share`
* @type {string|null}
*/
const parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
return new Item(
id, null, origin, null, rightOrigin,
canCopyParentInfo && !hasParentYKey ? readID(decoder) : (parentYKey ? doc.get(parentYKey) : null), // parent
canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null, // parentSub
/** @type {AbstractContent} */ (readItemContent(decoder, info)) // item content
)
}

View File

@@ -1,60 +0,0 @@
import {
AbstractStruct,
UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding'
export const structSkipRefNumber = 10
/**
* @private
*/
export class Skip extends AbstractStruct {
get deleted () {
return true
}
delete () {}
/**
* @param {Skip} right
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction, offset) {
// skip structs cannot be integrated
error.unexpectedCase()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeInfo(structSkipRefNumber)
// write as VarUint because Skips can't make use of predictable length-encoding
encoding.writeVarUint(encoder.restEncoder, this.length - offset)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | number}
*/
getMissing (transaction, store) {
return null
}
}

View File

@@ -11,203 +11,13 @@ import {
ContentAny,
ContentBinary,
getItemCleanStart,
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
ID, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
import * as iterator from 'lib0/iterator'
import * as error from 'lib0/error'
import * as math from 'lib0/math'
const maxSearchMarker = 80
/**
* A unique timestamp that identifies each marker.
*
* Time is relative,.. this is more like an ever-increasing clock.
*
* @type {number}
*/
let globalSearchMarkerTimestamp = 0
export class ArraySearchMarker {
/**
* @param {Item} p
* @param {number} index
*/
constructor (p, index) {
p.marker = true
this.p = p
this.index = index
this.timestamp = globalSearchMarkerTimestamp++
}
}
/**
* @param {ArraySearchMarker} marker
*/
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }
/**
* This is rather complex so this function is the only thing that should overwrite a marker
*
* @param {ArraySearchMarker} marker
* @param {Item} p
* @param {number} index
*/
const overwriteMarker = (marker, p, index) => {
marker.p.marker = false
marker.p = p
p.marker = true
marker.index = index
marker.timestamp = globalSearchMarkerTimestamp++
}
/**
* @param {Array<ArraySearchMarker>} searchMarker
* @param {Item} p
* @param {number} index
*/
const markPosition = (searchMarker, p, index) => {
if (searchMarker.length >= maxSearchMarker) {
// override oldest marker (we don't want to create more objects)
const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
overwriteMarker(marker, p, index)
return marker
} else {
// create new marker
const pm = new ArraySearchMarker(p, index)
searchMarker.push(pm)
return pm
}
}
/**
* Search marker help us to find positions in the associative array faster.
*
* They speed up the process of finding a position without much bookkeeping.
*
* A maximum of `maxSearchMarker` objects are created.
*
* This function always returns a refreshed marker (updated timestamp)
*
* @param {AbstractType<any>} yarray
* @param {number} index
*/
export const findMarker = (yarray, index) => {
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
return null
}
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
let p = yarray._start
let pindex = 0
if (marker !== null) {
p = marker.p
pindex = marker.index
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
}
// iterate to right if possible
while (p.right !== null && pindex < index) {
if (!p.deleted && p.countable) {
if (index < pindex + p.length) {
break
}
pindex += p.length
}
p = p.right
}
// iterate to left if necessary (might be that pindex > index)
while (p.left !== null && pindex > index) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// we want to make sure that p can't be merged with left, because that would screw up everything
// in that cas just return what we have (it is most likely the best marker anyway)
// iterate to left until p can't be merged with left
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// @todo remove!
// assure position
// {
// let start = yarray._start
// let pos = 0
// while (start !== p) {
// if (!start.deleted && start.countable) {
// pos += start.length
// }
// start = /** @type {Item} */ (start.right)
// }
// if (pos !== pindex) {
// debugger
// throw new Error('Gotcha position fail!')
// }
// }
// if (marker) {
// if (window.lengthes == null) {
// window.lengthes = []
// window.getLengthes = () => window.lengthes.sort((a, b) => a - b)
// }
// window.lengthes.push(marker.index - pindex)
// console.log('distance', marker.index - pindex, 'len', p && p.parent.length)
// }
if (marker !== null && math.abs(marker.index - pindex) < /** @type {YText|YArray<any>} */ (p.parent).length / maxSearchMarker) {
// adjust existing marker
overwriteMarker(marker, p, pindex)
return marker
} else {
// create new marker
return markPosition(yarray._searchMarker, p, pindex)
}
}
/**
* Update markers when a change happened.
*
* This should be called before doing a deletion!
*
* @param {Array<ArraySearchMarker>} searchMarker
* @param {number} index
* @param {number} len If insertion, len is positive. If deletion, len is negative.
*/
export const updateMarkerChanges = (searchMarker, index, len) => {
for (let i = searchMarker.length - 1; i >= 0; i--) {
const m = searchMarker[i]
if (len > 0) {
/**
* @type {Item|null}
*/
let p = m.p
p.marker = false
// Ideally we just want to do a simple position comparison, but this will only work if
// search markers don't point to deleted items for formats.
// Iterate marker to prev undeleted countable position so we know what to do when updating a position
while (p && (p.deleted || !p.countable)) {
p = p.left
if (p && !p.deleted && p.countable) {
// adjust position. the loop should break now
m.index -= p.length
}
}
if (p === null || p.marker === true) {
// remove search marker if updated position is null or if position is already marked
searchMarker.splice(i, 1)
continue
}
m.p = p
p.marker = true
}
if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
m.index = math.max(index, m.index + len)
}
}
}
import * as map from 'lib0/map.js'
import * as iterator from 'lib0/iterator.js'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
/**
* Accumulate all (list) children of a type and return them as an Array.
@@ -278,20 +88,9 @@ export class AbstractType {
this._eH = createEventHandler()
/**
* Deep event handlers
* @type {EventHandler<Array<YEvent<any>>,Transaction>}
* @type {EventHandler<Array<YEvent>,Transaction>}
*/
this._dEH = createEventHandler()
/**
* @type {null | Array<ArraySearchMarker>}
*/
this._searchMarker = null
}
/**
* @return {AbstractType<any>|null}
*/
get parent () {
return this._item ? /** @type {AbstractType<any>} */ (this._item.parent) : null
}
/**
@@ -317,14 +116,7 @@ export class AbstractType {
}
/**
* @return {AbstractType<EventType>}
*/
clone () {
throw error.methodUnimplemented()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
*/
_write (encoder) { }
@@ -346,11 +138,7 @@ export class AbstractType {
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
if (!transaction.local && this._searchMarker) {
this._searchMarker.length = 0
}
}
_callObserver (transaction, parentSubs) { /* skip if no type is specified */ }
/**
* Observe all events that are created on this type.
@@ -364,7 +152,7 @@ export class AbstractType {
/**
* Observe all events that are created by this type and its children.
*
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
* @param {function(Array<YEvent>,Transaction):void} f Observer function
*/
observeDeep (f) {
addEventHandlerListener(this._dEH, f)
@@ -382,7 +170,7 @@ export class AbstractType {
/**
* Unregister an observer function.
*
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
* @param {function(Array<YEvent>,Transaction):void} f Observer function
*/
unobserveDeep (f) {
removeEventHandlerListener(this._dEH, f)
@@ -395,43 +183,6 @@ export class AbstractType {
toJSON () {}
}
/**
* @param {AbstractType<any>} type
* @param {number} start
* @param {number} end
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListSlice = (type, start, end) => {
if (start < 0) {
start = type._length + start
}
if (end < 0) {
end = type._length + end
}
let len = end - start
const cs = []
let n = type._start
while (n !== null && len > 0) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
if (c.length <= start) {
start -= c.length
} else {
for (let i = start; i < c.length && len > 0; i++) {
cs.push(c[i])
len--
}
start = 0
}
}
n = n.right
}
return cs
}
/**
* @param {AbstractType<any>} type
* @return {Array<any>}
@@ -603,13 +354,7 @@ export const typeListForEachSnapshot = (type, f, snapshot) => {
* @function
*/
export const typeListGet = (type, index) => {
const marker = findMarker(type, index)
let n = type._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
for (; n !== null; n = n.right) {
for (let n = type._start; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
return n.content.getContent()[index]
@@ -623,7 +368,7 @@ export const typeListGet = (type, index) => {
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item?} referenceItem
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} content
* @param {Array<Object<string,any>|Array<any>|boolean|number|string|Uint8Array>} content
*
* @private
* @function
@@ -635,7 +380,7 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
const store = doc.store
const right = referenceItem === null ? parent._start : referenceItem.right
/**
* @type {Array<Object|Array<any>|number|null>}
* @type {Array<Object|Array<any>|number>}
*/
let jsonContent = []
const packJsonContent = () => {
@@ -646,77 +391,49 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
}
}
content.forEach(c => {
if (c === null) {
jsonContent.push(c)
} else {
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
break
default:
packJsonContent()
switch (c.constructor) {
case Uint8Array:
case ArrayBuffer:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
break
default:
packJsonContent()
switch (c.constructor) {
case Uint8Array:
case ArrayBuffer:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(transaction, 0)
break
default:
if (c instanceof AbstractType) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
left.integrate(transaction, 0)
break
case Doc:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (c)))
left.integrate(transaction, 0)
break
default:
if (c instanceof AbstractType) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
left.integrate(transaction, 0)
} else {
throw new Error('Unexpected content type in insert operation')
}
}
}
} else {
throw new Error('Unexpected content type in insert operation')
}
}
}
})
packJsonContent()
}
const lengthExceeded = error.create('Length exceeded!')
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
* @param {Array<Object<string,any>|Array<any>|number|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index > parent._length) {
throw lengthExceeded
}
if (index === 0) {
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, index, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, null, content)
}
const startIndex = index
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
// we need to iterate one to the left so that the algorithm works
if (index === 0) {
// @todo refactor this as it actually doesn't consider formats
n = n.prev // important! get the left undeleted item so that we can actually decrease index
index += (n && n.countable && !n.deleted) ? n.length : 0
}
}
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index <= n.length) {
@@ -729,32 +446,6 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
index -= n.length
}
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* Pushing content is special as we generally want to push after the last item. So we don't have to update
* the serach marker.
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListPushGenerics = (transaction, parent, content) => {
// Use the marker with the highest index and iterate to the right.
const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start })
let n = marker.p
if (n) {
while (n.right) {
n = n.right
}
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
@@ -769,14 +460,7 @@ export const typeListPushGenerics = (transaction, parent, content) => {
*/
export const typeListDelete = (transaction, parent, index, length) => {
if (length === 0) { return }
const startIndex = index
const startLength = length
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
// compute the first item to be deleted
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
@@ -798,10 +482,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
n = n.right
}
if (length > 0) {
throw lengthExceeded
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
throw error.create('array length exceeded')
}
}
@@ -824,7 +505,7 @@ export const typeMapDelete = (transaction, parent, key) => {
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} value
* @param {Object|number|Array<any>|string|Uint8Array|AbstractType<any>} value
*
* @private
* @function
@@ -848,9 +529,6 @@ export const typeMapSet = (transaction, parent, key, value) => {
case Uint8Array:
content = new ContentBinary(/** @type {Uint8Array} */ (value))
break
case Doc:
content = new ContentDoc(/** @type {Doc} */ (value))
break
default:
if (value instanceof AbstractType) {
content = new ContentType(value)
@@ -865,7 +543,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
@@ -877,7 +555,7 @@ export const typeMapGet = (parent, key) => {
/**
* @param {AbstractType<any>} parent
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
* @return {Object<string,Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
*
* @private
* @function
@@ -887,11 +565,11 @@ export const typeMapGetAll = (parent) => {
* @type {Object<string,any>}
*/
const res = {}
parent._map.forEach((value, key) => {
for (const [key, value] of parent._map) {
if (!value.deleted) {
res[key] = value.content.getContent()[value.length - 1]
}
})
}
return res
}
@@ -912,7 +590,7 @@ export const typeMapHas = (parent, key) => {
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Snapshot} snapshot
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function

View File

@@ -10,20 +10,20 @@ import {
typeListForEach,
typeListCreateIterator,
typeListInsertGenerics,
typeListPushGenerics,
typeListDelete,
typeListMap,
YArrayRefID,
callTypeObservers,
transact,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import { typeListSlice } from './AbstractType.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js'
/**
* Event that describes the changes on a YArray
* @template T
* @extends YEvent<YArray<T>>
*/
export class YArrayEvent extends YEvent {
/**
@@ -50,22 +50,6 @@ export class YArray extends AbstractType {
* @private
*/
this._prelimContent = []
/**
* @type {Array<ArraySearchMarker>}
*/
this._searchMarker = []
}
/**
* Construct a new YArray containing the specified items.
* @template T
* @param {Array<T>} items
* @return {YArray<T>}
*/
static from (items) {
const a = new YArray()
a.push(items)
return a
}
/**
@@ -88,17 +72,6 @@ export class YArray extends AbstractType {
return new YArray()
}
/**
* @return {YArray<T>}
*/
clone () {
const arr = new YArray()
arr.insert(0, this.toArray().map(el =>
el instanceof AbstractType ? el.clone() : el
))
return arr
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
@@ -110,7 +83,6 @@ export class YArray extends AbstractType {
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
super._callObserver(transaction, parentSubs)
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
}
@@ -144,17 +116,9 @@ export class YArray extends AbstractType {
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*
* @todo Use the following implementation in all types.
*/
push (content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListPushGenerics(transaction, this, content)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).push(...content)
}
this.insert(this.length, content)
}
/**
@@ -201,17 +165,6 @@ export class YArray extends AbstractType {
return typeListToArray(this)
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @param {number} [start]
* @param {number} [end]
* @return {Array<T>}
*/
slice (start = 0, end = this.length) {
return typeListSlice(this, start, end)
}
/**
* Transforms this Shared Type to a JSON object.
*
@@ -225,7 +178,7 @@ export class YArray extends AbstractType {
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @template M
* @template T,M
* @param {function(T,number,YArray<T>):M} f Function that produces an element of the new Array
* @return {Array<M>} A new array with each element being the result of the
* callback function
@@ -251,15 +204,15 @@ export class YArray extends AbstractType {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YArrayRefID)
encoding.writeVarUint(encoder, YArrayRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
*
* @private
* @function

View File

@@ -14,14 +14,15 @@ import {
YMapRefID,
callTypeObservers,
transact,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as iterator from 'lib0/iterator'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as iterator from 'lib0/iterator.js'
/**
* @template T
* @extends YEvent<YMap<T>>
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
@@ -37,11 +38,11 @@ export class YMapEvent extends YEvent {
}
/**
* @template MapType
* @template T number|string|Object|Array|Uint8Array
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<MapType>>
* @implements {Iterable<MapType>}
* @extends AbstractType<YMapEvent<T>>
* @implements {Iterable<T>}
*/
export class YMap extends AbstractType {
/**
@@ -75,9 +76,9 @@ export class YMap extends AbstractType {
*/
_integrate (y, item) {
super._integrate(y, item)
;/** @type {Map<string, any>} */ (this._prelimContent).forEach((value, key) => {
for (const [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
this.set(key, value)
})
}
this._prelimContent = null
}
@@ -85,17 +86,6 @@ export class YMap extends AbstractType {
return new YMap()
}
/**
* @return {YMap<MapType>}
*/
clone () {
const map = new YMap()
this.forEach((value, key) => {
map.set(key, value instanceof AbstractType ? value.clone() : value)
})
return map
}
/**
* Creates YMapEvent and calls observers.
*
@@ -109,19 +99,19 @@ export class YMap extends AbstractType {
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object<string,any>}
* @return {Object<string,T>}
*/
toJSON () {
/**
* @type {Object<string,MapType>}
* @type {Object<string,T>}
*/
const map = {}
this._map.forEach((item, key) => {
for (const [key, item] of this._map) {
if (!item.deleted) {
const v = item.content.getContent()[item.length - 1]
map[key] = v instanceof AbstractType ? v.toJSON() : v
}
})
}
return map
}
@@ -144,9 +134,9 @@ export class YMap extends AbstractType {
}
/**
* Returns the values for each element in the YMap Type.
* Returns the keys for each element in the YMap Type.
*
* @return {IterableIterator<any>}
* @return {IterableIterator<string>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
@@ -164,25 +154,23 @@ export class YMap extends AbstractType {
/**
* Executes a provided function on once on every key-value pair.
*
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
/**
* @type {Object<string,MapType>}
* @type {Object<string,T>}
*/
const map = {}
this._map.forEach((item, key) => {
for (const [key, item] of this._map) {
if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this)
}
})
}
return map
}
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<any>}
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return this.entries()
@@ -207,7 +195,7 @@ export class YMap extends AbstractType {
* Adds or updates an element with a specified key and value.
*
* @param {string} key The key of the element to add to this YMap
* @param {MapType} value The value of the element to add
* @param {T} value The value of the element to add
*/
set (key, value) {
if (this.doc !== null) {
@@ -224,7 +212,7 @@ export class YMap extends AbstractType {
* Returns a specified element from this YMap.
*
* @param {string} key
* @return {MapType|undefined}
* @return {T|undefined}
*/
get (key) {
return /** @type {any} */ (typeMapGet(this, key))
@@ -241,30 +229,15 @@ export class YMap extends AbstractType {
}
/**
* Removes all elements from this YMap.
*/
clear () {
if (this.doc !== null) {
transact(this.doc, transaction => {
this.forEach(function (value, key, map) {
typeMapDelete(transaction, map, key)
})
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).clear()
}
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YMapRefID)
encoding.writeVarUint(encoder, YMapRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
*
* @private
* @function

View File

@@ -20,19 +20,13 @@ import {
splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
findMarker,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapGetAll,
updateMarkerChanges,
ContentType,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as object from 'lib0/object'
import * as map from 'lib0/map'
import * as error from 'lib0/error'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js'
import * as object from 'lib0/object.js'
import * as map from 'lib0/map.js'
/**
* @param {any} a
@@ -41,77 +35,75 @@ import * as error from 'lib0/error'
*/
const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
export class ItemTextListPosition {
export class ItemListPosition {
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {number} index
* @param {Map<string,any>} currentAttributes
*/
constructor (left, right, index, currentAttributes) {
constructor (left, right) {
this.left = left
this.right = right
this.index = index
}
}
export class ItemTextListPosition extends ItemListPosition {
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
*/
constructor (left, right, currentAttributes) {
super(left, right)
this.currentAttributes = currentAttributes
}
}
export class ItemInsertionResult extends ItemListPosition {
/**
* Only call this if you know that this.right is defined
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} negatedAttributes
*/
forward () {
if (this.right === null) {
error.unexpectedCase()
}
switch (this.right.content.constructor) {
case ContentFormat:
if (!this.right.deleted) {
updateCurrentAttributes(this.currentAttributes, /** @type {ContentFormat} */ (this.right.content))
}
break
default:
if (!this.right.deleted) {
this.index += this.right.length
}
break
}
this.left = this.right
this.right = this.right.right
constructor (left, right, negatedAttributes) {
super(left, right)
this.negatedAttributes = negatedAttributes
}
}
/**
* @param {Transaction} transaction
* @param {ItemTextListPosition} pos
* @param {number} count steps to move forward
* @param {Map<string,any>} currentAttributes
* @param {Item|null} left
* @param {Item|null} right
* @param {number} count
* @return {ItemTextListPosition}
*
* @private
* @function
*/
const findNextPosition = (transaction, pos, count) => {
while (pos.right !== null && count > 0) {
switch (pos.right.content.constructor) {
case ContentFormat:
if (!pos.right.deleted) {
updateCurrentAttributes(pos.currentAttributes, /** @type {ContentFormat} */ (pos.right.content))
const findNextPosition = (transaction, currentAttributes, left, right, count) => {
while (right !== null && count > 0) {
switch (right.content.constructor) {
case ContentEmbed:
case ContentString:
if (!right.deleted) {
if (count < right.length) {
// split right
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + count))
}
count -= right.length
}
break
default:
if (!pos.right.deleted) {
if (count < pos.right.length) {
// split right
getItemCleanStart(transaction, createID(pos.right.id.client, pos.right.id.clock + count))
}
pos.index += pos.right.length
count -= pos.right.length
case ContentFormat:
if (!right.deleted) {
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
}
break
}
pos.left = pos.right
pos.right = pos.right.right
// pos.forward() - we don't forward because that would halve the performance because we already do the checks above
left = right
right = right.right
}
return pos
return new ItemTextListPosition(left, right, currentAttributes)
}
/**
@@ -125,14 +117,8 @@ const findNextPosition = (transaction, pos, count) => {
*/
const findPosition = (transaction, parent, index) => {
const currentAttributes = new Map()
const marker = findMarker(parent, index)
if (marker) {
const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes)
return findNextPosition(transaction, pos, index - marker.index)
} else {
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes)
return findNextPosition(transaction, pos, index)
}
const right = parent._start
return findNextPosition(transaction, currentAttributes, null, right, index)
}
/**
@@ -140,37 +126,37 @@ const findPosition = (transaction, parent, index) => {
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {ItemListPosition} currPos
* @param {Map<string,any>} negatedAttributes
*
* @private
* @function
*/
const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => {
let { left, right } = currPos
// check if we really need to remove attributes
while (
currPos.right !== null && (
currPos.right.deleted === true || (
currPos.right.content.constructor === ContentFormat &&
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value)
right !== null && (
right.deleted === true || (
right.content.constructor === ContentFormat &&
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
)
)
) {
if (!currPos.right.deleted) {
negatedAttributes.delete(/** @type {ContentFormat} */ (currPos.right.content).key)
if (!right.deleted) {
negatedAttributes.delete(/** @type {ContentFormat} */ (right.content).key)
}
currPos.forward()
left = right
right = right.right
}
const doc = transaction.doc
const ownClientId = doc.clientID
negatedAttributes.forEach((val, key) => {
const left = currPos.left
const right = currPos.right
const nextFormat = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
nextFormat.integrate(transaction, 0)
currPos.right = nextFormat
currPos.forward()
})
for (const [key, val] of negatedAttributes) {
left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction, 0)
}
currPos.left = left
currPos.right = right
}
/**
@@ -190,51 +176,59 @@ const updateCurrentAttributes = (currentAttributes, format) => {
}
/**
* @param {ItemTextListPosition} currPos
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
*
* @private
* @function
*/
const minimizeAttributeChanges = (currPos, attributes) => {
const minimizeAttributeChanges = (currPos, currentAttributes, attributes) => {
// go right while attributes[right.key] === right.value (or right is deleted)
let { left, right } = currPos
while (true) {
if (currPos.right === null) {
if (right === null) {
break
} else if (currPos.right.deleted || (currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] || null, /** @type {ContentFormat} */ (currPos.right.content).value))) {
//
} else if (right.deleted) {
// continue
} else if (right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (right.content)).key] || null, /** @type {ContentFormat} */ (right.content).value)) {
// found a format, update currentAttributes and continue
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
} else {
break
}
currPos.forward()
left = right
right = right.right
}
currPos.left = left
currPos.right = right
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
* @return {Map<string,any>}
*
* @private
* @function
**/
const insertAttributes = (transaction, parent, currPos, attributes) => {
const insertAttributes = (transaction, parent, currPos, currentAttributes, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
const negatedAttributes = new Map()
// insert format-start items
for (const key in attributes) {
const val = attributes[key]
const currentVal = currPos.currentAttributes.get(key) || null
const currentVal = currentAttributes.get(key) || null
if (!equalAttrs(currentVal, val)) {
// save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal)
const { left, right } = currPos
currPos.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
currPos.right.integrate(transaction, 0)
currPos.forward()
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
currPos.left.integrate(transaction, 0)
}
}
return negatedAttributes
@@ -243,96 +237,79 @@ const insertAttributes = (transaction, parent, currPos, attributes) => {
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {string|object|AbstractType<any>} text
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {string|object} text
* @param {Object<string,any>} attributes
*
* @private
* @function
**/
const insertText = (transaction, parent, currPos, text, attributes) => {
currPos.currentAttributes.forEach((val, key) => {
const insertText = (transaction, parent, currPos, currentAttributes, text, attributes) => {
for (const [key] of currentAttributes) {
if (attributes[key] === undefined) {
attributes[key] = null
}
})
}
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(currPos, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
minimizeAttributeChanges(currPos, currentAttributes, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
// insert content
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : (text instanceof AbstractType ? new ContentType(text) : new ContentEmbed(text))
let { left, right, index } = currPos
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength())
}
right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
right.integrate(transaction, 0)
currPos.right = right
currPos.index = index
currPos.forward()
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : new ContentEmbed(text)
const { left, right } = currPos
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
currPos.left.integrate(transaction, 0)
return insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {number} length
* @param {Object<string,any>} attributes
*
* @private
* @function
*/
const formatText = (transaction, parent, currPos, length, attributes) => {
const formatText = (transaction, parent, currPos, currentAttributes, length, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(currPos, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
minimizeAttributeChanges(currPos, currentAttributes, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
let { left, right } = currPos
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
// also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
// eslint-disable-next-line no-labels
iterationLoop: while (
currPos.right !== null &&
(length > 0 ||
(
negatedAttributes.size > 0 &&
(currPos.right.deleted || currPos.right.content.constructor === ContentFormat)
)
)
) {
if (!currPos.right.deleted) {
switch (currPos.right.content.constructor) {
while (length > 0 && right !== null) {
if (!right.deleted) {
switch (right.content.constructor) {
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (currPos.right.content)
const { key, value } = /** @type {ContentFormat} */ (right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
if (length === 0) {
// no need to further extend negatedAttributes
// eslint-disable-next-line no-labels
break iterationLoop
}
negatedAttributes.set(key, value)
}
currPos.right.delete(transaction)
} else {
currPos.currentAttributes.set(key, value)
right.delete(transaction)
}
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
break
}
default:
if (length < currPos.right.length) {
getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length))
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= currPos.right.length
length -= right.length
break
}
}
currPos.forward()
left = right
right = right.right
}
// Quill just assumes that the editor starts with a newline and that it always
// ends with a newline. We only insert that newline when a new newline is
@@ -342,10 +319,11 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
for (; length > 0; length--) {
newlines += '\n'
}
currPos.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), currPos.left, currPos.left && currPos.left.lastId, currPos.right, currPos.right && currPos.right.id, parent, null, new ContentString(newlines))
currPos.right.integrate(transaction, 0)
currPos.forward()
left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentString(newlines))
left.integrate(transaction, 0)
}
currPos.left = left
currPos.right = right
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
@@ -355,28 +333,22 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
*
* @param {Transaction} transaction
* @param {Item} start
* @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startAttributes
* @param {Map<string,any>} currAttributes
* @param {Map<string,any>} endAttributes This attribute is modified!
* @return {number} The amount of formatting Items deleted.
*
* @function
*/
const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => {
let end = curr
const endAttributes = map.copy(currAttributes)
while (end && (!end.countable || end.deleted)) {
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
}
end = end.right
}
let cleanups = 0
let reachedEndOfCurr = false
while (start !== end) {
if (curr === start) {
reachedEndOfCurr = true
}
if (!start.deleted) {
const content = start.content
switch (content.constructor) {
@@ -386,9 +358,6 @@ const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAtt
// Either this format is overwritten or it is not necessary because the attribute already existed.
start.delete(transaction)
cleanups++
if (!reachedEndOfCurr && (currAttributes.get(key) || null) === value && (startAttributes.get(key) || null) !== value) {
currAttributes.delete(key)
}
}
break
}
@@ -405,12 +374,12 @@ const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAtt
*/
const cleanupContextlessFormattingGap = (transaction, item) => {
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || !item.right.countable)) {
while (item && item.right && (item.right.deleted || (item.right.content.constructor !== ContentString && item.right.content.constructor !== ContentEmbed))) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || !item.countable)) {
while (item && (item.deleted || (item.content.constructor !== ContentString && item.content.constructor !== ContentEmbed))) {
if (!item.deleted && item.content.constructor === ContentFormat) {
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
@@ -448,7 +417,8 @@ export const cleanupYTextFormatting = type => {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
break
default:
case ContentEmbed:
case ContentString:
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
startAttributes = map.copy(currentAttributes)
start = end
@@ -463,40 +433,42 @@ export const cleanupYTextFormatting = type => {
/**
* @param {Transaction} transaction
* @param {ItemTextListPosition} currPos
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {number} length
* @return {ItemTextListPosition}
* @return {ItemListPosition}
*
* @private
* @function
*/
const deleteText = (transaction, currPos, length) => {
const startLength = length
const startAttrs = map.copy(currPos.currentAttributes)
const deleteText = (transaction, currPos, currentAttributes, length) => {
const startAttrs = map.copy(currentAttributes)
const start = currPos.right
while (length > 0 && currPos.right !== null) {
if (currPos.right.deleted === false) {
switch (currPos.right.content.constructor) {
case ContentType:
let { left, right } = currPos
while (length > 0 && right !== null) {
if (right.deleted === false) {
switch (right.content.constructor) {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
break
case ContentEmbed:
case ContentString:
if (length < currPos.right.length) {
getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length))
if (length < right.length) {
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= currPos.right.length
currPos.right.delete(transaction)
length -= right.length
right.delete(transaction)
break
}
}
currPos.forward()
left = right
right = right.right
}
if (start) {
cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes)
}
const parent = /** @type {AbstractType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length)
cleanupFormattingGap(transaction, start, right, startAttrs, map.copy(currentAttributes))
}
currPos.left = left
currPos.right = right
return currPos
}
@@ -528,72 +500,43 @@ const deleteText = (transaction, currPos, length) => {
*/
/**
* @extends YEvent<YText>
* @typedef {Object} DeltaItem
* @property {number|undefined} DeltaItem.delete
* @property {number|undefined} DeltaItem.retain
* @property {string|undefined} DeltaItem.string
* @property {Object<string,any>} DeltaItem.attributes
*/
/**
* Event that describes the changes on a YText type.
*/
export class YTextEvent extends YEvent {
/**
* @param {YText} ytext
* @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed
*/
constructor (ytext, transaction, subs) {
constructor (ytext, transaction) {
super(ytext, transaction)
/**
* Whether the children changed.
* @type {Boolean}
* @private
* @type {Array<DeltaItem>|null}
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string>}
*/
this.keysChanged = new Set()
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.keysChanged.add(sub)
}
})
}
/**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
*/
get changes () {
if (this._changes === null) {
/**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string|AbstractType<any>|object, delete?:number, retain?:number}>}}
*/
const changes = {
keys: this.keys,
delta: this.delta,
added: new Set(),
deleted: new Set()
}
this._changes = changes
}
return /** @type {any} */ (this._changes)
this._delta = null
}
/**
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
*
* @type {Array<{insert?:string|object|AbstractType<any>, delete?:number, retain?:number, attributes?: Object<string,any>}>}
* @type {Array<DeltaItem>}
*
* @public
*/
get delta () {
if (this._delta === null) {
const y = /** @type {Doc} */ (this.target.doc)
/**
* @type {Array<{insert?:string|object|AbstractType<any>, delete?:number, retain?:number, attributes?: Object<string,any>}>}
*/
const delta = []
this._delta = []
transact(y, transaction => {
const delta = /** @type {Array<DeltaItem>} */ (this._delta)
const currentAttributes = new Map() // saves all current attributes for insert
const oldAttributes = new Map()
let item = this.target._start
@@ -626,11 +569,11 @@ export class YTextEvent extends YEvent {
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
currentAttributes.forEach((value, key) => {
for (const [key, value] of currentAttributes) {
if (value !== null) {
op.attributes[key] = value
}
})
}
}
insert = ''
break
@@ -651,13 +594,12 @@ export class YTextEvent extends YEvent {
}
while (item !== null) {
switch (item.content.constructor) {
case ContentType:
case ContentEmbed:
if (this.adds(item)) {
if (!this.deletes(item)) {
addOp()
action = 'insert'
insert = item.content.getContent()[0]
insert = /** @type {ContentEmbed} */ (item.content).embed
addOp()
}
} else if (this.deletes(item)) {
@@ -711,7 +653,7 @@ export class YTextEvent extends YEvent {
} else {
attributes[key] = value
}
} else if (value !== null) {
} else {
item.delete(transaction)
}
}
@@ -733,11 +675,11 @@ export class YTextEvent extends YEvent {
addOp()
}
if (value === null) {
delete attributes[key]
} else {
attributes[key] = value
} else {
delete attributes[key]
}
} else if (attr !== null) { // this will be cleaned up automatically by the contextless cleanup function
} else {
item.delete(transaction)
}
}
@@ -764,9 +706,8 @@ export class YTextEvent extends YEvent {
}
}
})
this._delta = delta
}
return /** @type {any} */ (this._delta)
return this._delta
}
}
@@ -790,10 +731,6 @@ export class YText extends AbstractType {
* @type {Array<function():void>?}
*/
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
/**
* @type {Array<ArraySearchMarker>}
*/
this._searchMarker = []
}
/**
@@ -823,15 +760,6 @@ export class YText extends AbstractType {
return new YText()
}
/**
* @return {YText}
*/
clone () {
const text = new YText()
text.applyDelta(this.toDelta())
return text
}
/**
* Creates YTextEvent and calls observers.
*
@@ -839,21 +767,20 @@ export class YText extends AbstractType {
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
super._callObserver(transaction, parentSubs)
const event = new YTextEvent(this, transaction, parentSubs)
const event = new YTextEvent(this, transaction)
const doc = transaction.doc
callTypeObservers(this, transaction, event)
// If a remote change happened, we try to cleanup potential formatting duplicates.
if (!transaction.local) {
// check if another formatting item was inserted
let foundFormattingItem = false
for (const [client, afterClock] of transaction.afterState.entries()) {
for (const [client, afterClock] of transaction.afterState) {
const clock = transaction.beforeState.get(client) || 0
if (afterClock === clock) {
continue
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
if (!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat) {
// @ts-ignore
if (!item.deleted && item.content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
@@ -861,17 +788,7 @@ export class YText extends AbstractType {
break
}
}
if (!foundFormattingItem) {
iterateDeletedStructs(transaction, transaction.deleteSet, item => {
if (item instanceof GC || foundFormattingItem) {
return
}
if (item.parent === this && item.content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
}
transact(doc, (t) => {
transact(doc, t => {
if (foundFormattingItem) {
// If a formatting item was inserted, we simply clean the whole type.
// We need to compute currentAttributes for the current position anyway.
@@ -880,7 +797,7 @@ export class YText extends AbstractType {
// If no formatting attribute was inserted, we can make due with contextless
// formatting cleanups.
// Contextless: it is not necessary to compute currentAttributes for the affected position.
iterateDeletedStructs(t, t.deleteSet, item => {
iterateDeletedStructs(t, transaction.deleteSet, item => {
if (item instanceof GC) {
return
}
@@ -891,6 +808,7 @@ export class YText extends AbstractType {
}
})
}
callTypeObservers(this, transaction, event)
}
/**
@@ -936,7 +854,11 @@ export class YText extends AbstractType {
applyDelta (delta, { sanitize = true } = {}) {
if (this.doc !== null) {
transact(this.doc, transaction => {
const currPos = new ItemTextListPosition(null, this._start, 0, new Map())
/**
* @type {ItemListPosition}
*/
const currPos = new ItemListPosition(null, this._start)
const currentAttributes = new Map()
for (let i = 0; i < delta.length; i++) {
const op = delta[i]
if (op.insert !== undefined) {
@@ -947,12 +869,12 @@ export class YText extends AbstractType {
// paragraphs, but nothing bad will happen.
const ins = (!sanitize && typeof op.insert === 'string' && i === delta.length - 1 && currPos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
if (typeof ins !== 'string' || ins.length > 0) {
insertText(transaction, this, currPos, ins, op.attributes || {})
insertText(transaction, this, currPos, currentAttributes, ins, op.attributes || {})
}
} else if (op.retain !== undefined) {
formatText(transaction, this, currPos, op.retain, op.attributes || {})
formatText(transaction, this, currPos, currentAttributes, op.retain, op.attributes || {})
} else if (op.delete !== undefined) {
deleteText(transaction, currPos, op.delete)
deleteText(transaction, currPos, currentAttributes, op.delete)
}
}
})
@@ -988,10 +910,10 @@ export class YText extends AbstractType {
*/
const attributes = {}
let addAttributes = false
currentAttributes.forEach((value, key) => {
for (const [key, value] of currentAttributes) {
addAttributes = true
attributes[key] = value
})
}
/**
* @type {Object<string,any>}
*/
@@ -1034,21 +956,20 @@ export class YText extends AbstractType {
str += /** @type {ContentString} */ (n.content).str
break
}
case ContentType:
case ContentEmbed: {
packStr()
/**
* @type {Object<string,any>}
*/
const op = {
insert: n.content.getContent()[0]
insert: /** @type {ContentEmbed} */ (n.content).embed
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
currentAttributes.forEach((value, key) => {
for (const [key, value] of currentAttributes) {
attrs[key] = value
})
}
}
ops.push(op)
break
@@ -1085,13 +1006,13 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const pos = findPosition(transaction, this, index)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (!attributes) {
attributes = {}
// @ts-ignore
pos.currentAttributes.forEach((v, k) => { attributes[k] = v })
currentAttributes.forEach((v, k) => { attributes[k] = v })
}
insertText(transaction, this, pos, text, attributes)
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, text, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insert(index, text, attributes))
@@ -1102,18 +1023,21 @@ export class YText extends AbstractType {
* Inserts an embed at a index.
*
* @param {number} index The index to insert the embed at.
* @param {Object | AbstractType<any>} embed The Object that represents the embed.
* @param {Object} embed The Object that represents the embed.
* @param {TextAttributes} attributes Attribute information to apply on the
* embed
*
* @public
*/
insertEmbed (index, embed, attributes = {}) {
if (embed.constructor !== Object) {
throw new Error('Embed must be an Object')
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const pos = findPosition(transaction, this, index)
insertText(transaction, this, pos, embed, attributes)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, embed, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insertEmbed(index, embed, attributes))
@@ -1135,7 +1059,8 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
deleteText(transaction, findPosition(transaction, this, index), length)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
deleteText(transaction, new ItemListPosition(left, right), currentAttributes, length)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.delete(index, length))
@@ -1159,11 +1084,11 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const pos = findPosition(transaction, this, index)
if (pos.right === null) {
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (right === null) {
return
}
formatText(transaction, this, pos, length, attributes)
formatText(transaction, this, new ItemListPosition(left, right), currentAttributes, length, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))
@@ -1171,83 +1096,15 @@ export class YText extends AbstractType {
}
/**
* Removes an attribute.
*
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
*
* @param {String} attributeName The attribute name that is to be removed.
*
* @public
*/
removeAttribute (attributeName) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapDelete(transaction, this, attributeName)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.removeAttribute(attributeName))
}
}
/**
* Sets or updates an attribute.
*
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
*
* @param {String} attributeName The attribute name that is to be set.
* @param {any} attributeValue The attribute value that is to be set.
*
* @public
*/
setAttribute (attributeName, attributeValue) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, attributeName, attributeValue)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.setAttribute(attributeName, attributeValue))
}
}
/**
* Returns an attribute value that belongs to the attribute name.
*
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
*
* @param {String} attributeName The attribute name that identifies the
* queried value.
* @return {any} The queried attribute value.
*
* @public
*/
getAttribute (attributeName) {
return /** @type {any} */ (typeMapGet(this, attributeName))
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
*
* @param {Snapshot} [snapshot]
* @return {Object<string, any>} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes (snapshot) {
return typeMapGetAll(this)
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YTextRefID)
encoding.writeVarUint(encoder, YTextRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {YText}
*
* @private

View File

@@ -3,15 +3,17 @@ import {
YXmlFragment,
transact,
typeMapDelete,
typeMapHas,
typeMapSet,
typeMapGet,
typeMapGetAll,
typeListForEach,
YXmlElementRefID,
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Snapshot, Doc, Item // eslint-disable-line
Snapshot, Doc, Item // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
@@ -29,22 +31,6 @@ export class YXmlElement extends YXmlFragment {
this._prelimAttrs = new Map()
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get nextSibling () {
const n = this._item ? this._item.next : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get prevSibling () {
const n = this._item ? this._item.prev : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* Integrate this type into the Yjs instance.
*
@@ -72,20 +58,6 @@ export class YXmlElement extends YXmlFragment {
return new YXmlElement(this.nodeName)
}
/**
* @return {YXmlElement}
*/
clone () {
const el = new YXmlElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
el.setAttribute(key, attrs[key])
}
// @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
}
/**
* Returns the XML serialization of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
@@ -161,18 +133,6 @@ export class YXmlElement extends YXmlFragment {
return /** @type {any} */ (typeMapGet(this, attributeName))
}
/**
* Returns whether an attribute exists
*
* @param {String} attributeName The attribute name to check for existence.
* @return {boolean} whether the attribute exists.
*
* @public
*/
hasAttribute (attributeName) {
return /** @type {any} */ (typeMapHas(this, attributeName))
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
@@ -221,18 +181,18 @@ export class YXmlElement extends YXmlFragment {
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlElementRefID)
encoder.writeKey(this.nodeName)
encoding.writeVarUint(encoder, YXmlElementRefID)
encoding.writeVarString(encoder, this.nodeName)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {YXmlElement}
*
* @function
*/
export const readYXmlElement = decoder => new YXmlElement(decoder.readKey())
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))

View File

@@ -1,16 +1,15 @@
import {
YEvent,
YXmlText, YXmlElement, YXmlFragment, Transaction // eslint-disable-line
YXmlElement, YXmlFragment, Transaction // eslint-disable-line
} from '../internals.js'
/**
* @extends YEvent<YXmlElement|YXmlText|YXmlFragment>
* An Event that describes changes on a YXml Element or Yxml Fragment
*/
export class YXmlEvent extends YEvent {
/**
* @param {YXmlElement|YXmlText|YXmlFragment} target The target on which the event is created.
* @param {YXmlElement|YXmlFragment} target The target on which the event is created.
* @param {Set<string|null>} subs The set of changed attributes. `null` is included if the
* child list changed.
* @param {Transaction} transaction The transaction instance with wich the
@@ -26,7 +25,7 @@ export class YXmlEvent extends YEvent {
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string>}
* @type {Set<string|null>}
*/
this.attributesChanged = new Set()
subs.forEach((sub) => {

View File

@@ -9,18 +9,16 @@ import {
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListInsertGenericsAfter,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
typeListGet,
typeListSlice,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
/**
* Define the elements to which a set of CSS queries apply.
@@ -83,7 +81,7 @@ export class YXmlTreeWalker {
* @type {Item|null}
*/
let n = this._currentNode
let type = n && n.content && /** @type {any} */ (n.content).type
let type = /** @type {any} */ (n.content).type
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
do {
type = /** @type {any} */ (n.content).type
@@ -133,14 +131,6 @@ export class YXmlFragment extends AbstractType {
this._prelimContent = []
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get firstChild () {
const first = this._first
return first ? first.content.getContent()[0] : null
}
/**
* Integrate this type into the Yjs instance.
*
@@ -161,16 +151,6 @@ export class YXmlFragment extends AbstractType {
return new YXmlFragment()
}
/**
* @return {YXmlFragment}
*/
clone () {
const el = new YXmlFragment()
// @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
@@ -313,32 +293,6 @@ export class YXmlFragment extends AbstractType {
}
}
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {null|Item|YXmlElement|YXmlText} ref The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insertAfter (ref, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
const refItem = (ref && ref instanceof AbstractType) ? ref._item : ref
typeListInsertGenericsAfter(transaction, this, refItem, content)
})
} else {
const pc = /** @type {Array<any>} */ (this._prelimContent)
const index = ref === null ? 0 : pc.findIndex(el => el === ref) + 1
if (index === 0 && ref !== null) {
throw error.create('Reference item not found')
}
pc.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
@@ -365,69 +319,21 @@ export class YXmlFragment extends AbstractType {
return typeListToArray(this)
}
/**
* Appends content to this YArray.
*
* @param {Array<YXmlElement|YXmlText>} content Array of content to append.
*/
push (content) {
this.insert(this.length, content)
}
/**
* Preppends content to this YArray.
*
* @param {Array<YXmlElement|YXmlText>} content Array of content to preppend.
*/
unshift (content) {
this.insert(0, content)
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {YXmlElement|YXmlText}
*/
get (index) {
return typeListGet(this, index)
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @param {number} [start]
* @param {number} [end]
* @return {Array<YXmlElement|YXmlText>}
*/
slice (start = 0, end = this.length) {
return typeListSlice(this, start, end)
}
/**
* Executes a provided function on once on overy child element.
*
* @param {function(YXmlElement|YXmlText,number, typeof this):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
typeListForEach(this, f)
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlFragmentRefID)
encoding.writeVarUint(encoder, YXmlFragmentRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {YXmlFragment}
*
* @private

View File

@@ -1,9 +1,10 @@
import {
YMap,
YXmlHookRefID,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line
YXmlHookRefID
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* You can manage binding to a custom type with YXmlHook.
@@ -29,17 +30,6 @@ export class YXmlHook extends YMap {
return new YXmlHook(this.hookName)
}
/**
* @return {YXmlHook}
*/
clone () {
const el = new YXmlHook(this.hookName)
this.forEach((value, key) => {
el.set(key, value)
})
return el
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
@@ -76,20 +66,21 @@ export class YXmlHook extends YMap {
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlHookRefID)
encoder.writeKey(this.hookName)
super._write(encoder)
encoding.writeVarUint(encoder, YXmlHookRefID)
encoding.writeVarString(encoder, this.hookName)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {YXmlHook}
*
* @private
* @function
*/
export const readYXmlHook = decoder =>
new YXmlHook(decoder.readKey())
new YXmlHook(decoding.readVarString(decoder))

View File

@@ -1,44 +1,18 @@
import {
YText,
YXmlTextRefID,
ContentType, YXmlElement, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, // eslint-disable-line
} from '../internals.js'
import { YText, YXmlTextRefID } from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
/**
* @type {YXmlElement|YXmlText|null}
*/
get nextSibling () {
const n = this._item ? this._item.next : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get prevSibling () {
const n = this._item ? this._item.prev : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
_copy () {
return new YXmlText()
}
/**
* @return {YXmlText}
*/
clone () {
const text = new YXmlText()
text.applyDelta(this.toDelta())
return text
}
/**
* Creates a Dom Element that mirrors this YXmlText.
*
@@ -104,15 +78,15 @@ export class YXmlText extends YText {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YXmlTextRefID)
encoding.writeVarUint(encoder, YXmlTextRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {YXmlText}
*
* @private

View File

@@ -1,26 +0,0 @@
import { Observable } from 'lib0/observable'
import {
Doc // eslint-disable-line
} from '../internals.js'
/**
* This is an abstract interface that all Connectors should implement to keep them interchangeable.
*
* @note This interface is experimental and it is not advised to actually inherit this class.
* It just serves as typing information.
*
* @extends {Observable<any>}
*/
export class AbstractConnector extends Observable {
/**
* @param {Doc} ydoc
* @param {any} awareness
*/
constructor (ydoc, awareness) {
super()
this.doc = ydoc
this.awareness = awareness
}
}

View File

@@ -3,16 +3,16 @@ import {
findIndexSS,
getState,
splitItem,
createID,
iterateStructs,
UpdateEncoderV2,
DSDecoderV1, DSEncoderV1, DSDecoderV2, DSEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
Item, AbstractStruct, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array'
import * as math from 'lib0/math'
import * as map from 'lib0/map'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as array from 'lib0/array.js'
import * as math from 'lib0/math.js'
import * as map from 'lib0/map.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class DeleteItem {
/**
@@ -122,8 +122,8 @@ export const sortAndMergeDeleteSet = ds => {
for (i = 1, j = 1; i < dels.length; i++) {
const left = dels[j - 1]
const right = dels[i]
if (left.clock + left.len >= right.clock) {
left.len = math.max(left.len, right.clock + right.len - left.clock)
if (left.clock + left.len === right.clock) {
left.len += right.len
} else {
if (j < i) {
dels[j] = right
@@ -163,15 +163,14 @@ export const mergeDeleteSets = dss => {
/**
* @param {DeleteSet} ds
* @param {number} client
* @param {number} clock
* @param {ID} id
* @param {number} length
*
* @private
* @function
*/
export const addToDeleteSet = (ds, client, clock, length) => {
map.setIfUndefined(ds.clients, client, () => []).push(new DeleteItem(clock, length))
export const addToDeleteSet = (ds, id, length) => {
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
}
export const createDeleteSet = () => new DeleteSet()
@@ -196,7 +195,7 @@ export const createDeleteSetFromStructStore = ss => {
const clock = struct.id.clock
let len = struct.length
if (i + 1 < structs.length) {
for (let next = structs[i + 1]; i + 1 < structs.length && next.deleted; next = structs[++i + 1]) {
for (let next = structs[i + 1]; i + 1 < structs.length && next.id.clock === clock + len && next.deleted; next = structs[++i + 1]) {
len += next.length
}
}
@@ -211,29 +210,28 @@ export const createDeleteSetFromStructStore = ss => {
}
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {DeleteSet} ds
*
* @private
* @function
*/
export const writeDeleteSet = (encoder, ds) => {
encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
encoding.writeVarUint(encoder, ds.clients.size)
ds.clients.forEach((dsitems, client) => {
encoder.resetDsCurVal()
encoding.writeVarUint(encoder.restEncoder, client)
encoding.writeVarUint(encoder, client)
const len = dsitems.length
encoding.writeVarUint(encoder.restEncoder, len)
encoding.writeVarUint(encoder, len)
for (let i = 0; i < len; i++) {
const item = dsitems[i]
encoder.writeDsClock(item.clock)
encoder.writeDsLen(item.len)
encoding.writeVarUint(encoder, item.clock)
encoding.writeVarUint(encoder, item.len)
}
})
}
/**
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {DeleteSet}
*
* @private
@@ -241,49 +239,39 @@ export const writeDeleteSet = (encoder, ds) => {
*/
export const readDeleteSet = decoder => {
const ds = new DeleteSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
if (numberOfDeletes > 0) {
const dsField = map.setIfUndefined(ds.clients, client, () => [])
for (let i = 0; i < numberOfDeletes; i++) {
dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen()))
}
const client = decoding.readVarUint(decoder)
const numberOfDeletes = decoding.readVarUint(decoder)
for (let i = 0; i < numberOfDeletes; i++) {
addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder))
}
}
return ds
}
/**
* @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array()..
*/
/**
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @param {Transaction} transaction
* @param {StructStore} store
* @return {Uint8Array|null} Returns a v2 update containing all deletes that couldn't be applied yet; or null if all deletes were applied successfully.
*
* @private
* @function
*/
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
const client = decoding.readVarUint(decoder)
const numberOfDeletes = decoding.readVarUint(decoder)
const structs = store.clients.get(client) || []
const state = getState(store, client)
for (let i = 0; i < numberOfDeletes; i++) {
const clock = decoder.readDsClock()
const clockEnd = clock + decoder.readDsLen()
const clock = decoding.readVarUint(decoder)
const len = decoding.readVarUint(decoder)
if (clock < state) {
if (state < clockEnd) {
addToDeleteSet(unappliedDS, client, state, clockEnd - state)
if (state < clock + len) {
addToDeleteSet(unappliedDS, createID(client, state), clock + len - state)
}
let index = findIndexSS(structs, clock)
/**
@@ -300,10 +288,10 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => {
while (index < structs.length) {
// @ts-ignore
struct = structs[index++]
if (struct.id.clock < clockEnd) {
if (struct.id.clock < clock + len) {
if (!struct.deleted) {
if (clockEnd < struct.id.clock + struct.length) {
structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock))
if (clock + len < struct.id.clock + struct.length) {
structs.splice(index, 0, splitItem(transaction, struct, clock + len - struct.id.clock))
}
struct.delete(transaction)
}
@@ -312,15 +300,14 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => {
}
}
} else {
addToDeleteSet(unappliedDS, client, clock, clockEnd - clock)
addToDeleteSet(unappliedDS, createID(client, clock), len)
}
}
}
if (unappliedDS.clients.size > 0) {
const ds = new UpdateEncoderV2()
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
writeDeleteSet(ds, unappliedDS)
return ds.toUint8Array()
// TODO: no need for encoding+decoding ds anymore
const unappliedDSEncoder = encoding.createEncoder()
writeDeleteSet(unappliedDSEncoder, unappliedDS)
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))
}
return null
}

View File

@@ -10,45 +10,32 @@ import {
YMap,
YXmlFragment,
transact,
ContentDoc, Item, Transaction, YEvent // eslint-disable-line
Item, Transaction, YEvent // eslint-disable-line
} from '../internals.js'
import { Observable } from 'lib0/observable'
import * as random from 'lib0/random'
import * as map from 'lib0/map'
import * as array from 'lib0/array'
import * as promise from 'lib0/promise'
import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js'
export const generateNewClientId = random.uint32
/**
* @typedef {Object} DocOpts
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
*/
/**
* A Yjs instance handles the state of shared data.
* @extends Observable<string>
*/
export class Doc extends Observable {
/**
* @param {DocOpts} [opts] configuration
* @param {Object} conf configuration
* @param {boolean} [conf.gc] Disable garbage collection (default: gc=true)
* @param {function(Item):boolean} [conf.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
*/
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) {
constructor ({ gc = true, gcFilter = () => true } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
this.guid = guid
this.collectionid = collectionid
/**
* @type {Map<string, AbstractType<YEvent<any>>>}
* @type {Map<string, AbstractType<YEvent>>}
*/
this.share = new Map()
this.store = new StructStore()
@@ -60,50 +47,6 @@ export class Doc extends Observable {
* @type {Array<Transaction>}
*/
this._transactionCleanups = []
/**
* @type {Set<Doc>}
*/
this.subdocs = new Set()
/**
* If this document is a subdocument - a document integrated into another document - then _item is defined.
* @type {Item?}
*/
this._item = null
this.shouldLoad = shouldLoad
this.autoLoad = autoLoad
this.meta = meta
this.isLoaded = false
this.whenLoaded = promise.create(resolve => {
this.on('load', () => {
this.isLoaded = true
resolve(this)
})
})
}
/**
* Notify the parent document that you request to load data into this subdocument (if it is a subdocument).
*
* `load()` might be used in the future to request any provider to load the most current data.
*
* It is safe to call `load()` multiple times.
*/
load () {
const item = this._item
if (item !== null && !this.shouldLoad) {
transact(/** @type {any} */ (item.parent).doc, transaction => {
transaction.subdocsLoaded.add(this)
}, null, true)
}
this.shouldLoad = true
}
getSubdocs () {
return this.subdocs
}
getSubdocGuids () {
return new Set(Array.from(this.subdocs).map(doc => doc.guid))
}
/**
@@ -122,17 +65,17 @@ export class Doc extends Observable {
}
/**
* Define a shared data type.
* Get a shared data type by name. If it does not yet exist, define its type.
*
* Multiple calls of `y.get(name, TypeConstructor)` yield the same result
* and do not overwrite each other. I.e.
* `y.define(name, Y.Array) === y.define(name, Y.Array)`
* and do not overwrite each other, i.e.
* `y.get(name, Y.Array) === y.get(name, Y.Array)`
*
* After this method is called, the type is also available on `y.share.get(name)`.
*
* *Best Practices:*
* Define all types right after the Yjs instance is created and store them in a separate object.
* Also use the typed methods `getText(name)`, `getArray(name)`, ..
* Also use the typed methods `getText(name)`, `getArray(name)`, `getMap(name)`, etc.
*
* @example
* const y = new Y(..)
@@ -183,98 +126,60 @@ export class Doc extends Observable {
/**
* @template T
* @param {string} [name]
* @param {string} name
* @return {YArray<T>}
*
* @public
*/
getArray (name = '') {
getArray (name) {
// @ts-ignore
return this.get(name, YArray)
}
/**
* @param {string} [name]
* @param {string} name
* @return {YText}
*
* @public
*/
getText (name = '') {
getText (name) {
// @ts-ignore
return this.get(name, YText)
}
/**
* @template T
* @param {string} [name]
* @return {YMap<T>}
* @param {string} name
* @return {YMap<any>}
*
* @public
*/
getMap (name = '') {
getMap (name) {
// @ts-ignore
return this.get(name, YMap)
}
/**
* @param {string} [name]
* @param {string} name
* @return {YXmlFragment}
*
* @public
*/
getXmlFragment (name = '') {
getXmlFragment (name) {
// @ts-ignore
return this.get(name, YXmlFragment)
}
/**
* 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(..)).
*
* @deprecated Do not use this method and rather call toJSON directly on the shared types.
*
* @return {Object<string, any>}
*/
toJSON () {
/**
* @type {Object<string, any>}
*/
const doc = {}
this.share.forEach((value, key) => {
doc[key] = value.toJSON()
})
return doc
}
/**
* Emit `destroy` event and unregister all event handlers.
*/
destroy () {
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
const item = this._item
if (item !== null) {
this._item = null
const content = /** @type {ContentDoc} */ (item.content)
content.doc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
content.doc._item = item
transact(/** @type {any} */ (item).parent.doc, transaction => {
const doc = content.doc
if (!item.deleted) {
transaction.subdocsAdded.add(doc)
}
transaction.subdocsRemoved.add(this)
}, null, true)
}
this.emit('destroyed', [true])
this.emit('destroy', [this])
super.destroy()
}
/**
* @param {string} eventName
* @param {function(...any):any} f
* @param {function} f
*/
on (eventName, f) {
super.on(eventName, f)

View File

@@ -1,4 +1,4 @@
import * as f from 'lib0/function'
import * as f from 'lib0/function.js'
/**
* General event handler implementation.
@@ -51,12 +51,7 @@ export const addEventHandlerListener = (eventHandler, f) =>
* @function
*/
export const removeEventHandlerListener = (eventHandler, f) => {
const l = eventHandler.l
const len = l.length
eventHandler.l = l.filter(g => f !== g)
if (len === eventHandler.l.length) {
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
}
eventHandler.l = eventHandler.l.filter(g => f !== g)
}
/**

View File

@@ -1,9 +1,9 @@
import { AbstractType } from '../internals.js' // eslint-disable-line
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as error from 'lib0/error'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import * as error from 'lib0/error.js'
export class ID {
/**
@@ -81,7 +81,7 @@ export const readID = decoder =>
*/
export const findRootTypeKey = type => {
// @ts-ignore _y must be defined, otherwise unexpected case
for (const [key, value] of type.doc.share.entries()) {
for (const [key, value] of type.doc.share) {
if (value === type) {
return key
}

View File

@@ -5,11 +5,11 @@ import {
readDeleteSet,
writeDeleteSet,
createDeleteSet,
DSEncoderV1, DSDecoderV1, ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
export class PermanentUserData {
@@ -46,12 +46,12 @@ export class PermanentUserData {
event.changes.added.forEach(item => {
item.content.getContent().forEach(encodedDs => {
if (encodedDs instanceof Uint8Array) {
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs)))]))
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(decoding.createDecoder(encodedDs))]))
}
})
})
})
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs))))))
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(decoding.createDecoder(encodedDs)))))
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
)
@@ -97,11 +97,11 @@ export class PermanentUserData {
user.get('ids').push([clientid])
}
})
const encoder = new DSEncoderV1()
const encoder = encoding.createEncoder()
const ds = this.dss.get(userDescription)
if (ds) {
writeDeleteSet(encoder, ds)
user.get('ds').push([encoder.toUint8Array()])
user.get('ds').push([encoding.toUint8Array(encoder)])
}
}
}, 0)
@@ -111,9 +111,9 @@ export class PermanentUserData {
const yds = user.get('ds')
const ds = transaction.deleteSet
if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) {
const encoder = new DSEncoderV1()
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, ds)
yds.push([encoder.toUint8Array()])
yds.push([encoding.toUint8Array(encoder)])
}
})
})
@@ -132,7 +132,7 @@ export class PermanentUserData {
* @return {string | null}
*/
getUserByDeletedId (id) {
for (const [userDescription, ds] of this.dss.entries()) {
for (const [userDescription, ds] of this.dss) {
if (isDeleted(ds, id)) {
return userDescription
}

View File

@@ -12,9 +12,9 @@ import {
ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* A relative position is based on the Yjs model and is not affected by document changes.
@@ -45,9 +45,8 @@ export class RelativePosition {
* @param {ID|null} type
* @param {string|null} tname
* @param {ID|null} item
* @param {number} assoc
*/
constructor (type, tname, item, assoc = 0) {
constructor (type, tname, item) {
/**
* @type {ID|null}
*/
@@ -60,57 +59,23 @@ export class RelativePosition {
* @type {ID | null}
*/
this.item = item
/**
* A relative position is associated to a specific character. By default
* assoc >= 0, the relative position is associated to the character
* after the meant position.
* I.e. position 1 in 'ab' is associated to character 'b'.
*
* If assoc < 0, then the relative position is associated to the caharacter
* before the meant position.
*
* @type {number}
*/
this.assoc = assoc
}
}
/**
* @param {RelativePosition} rpos
* @return {any}
*/
export const relativePositionToJSON = rpos => {
const json = {}
if (rpos.type) {
json.type = rpos.type
}
if (rpos.tname) {
json.tname = rpos.tname
}
if (rpos.item) {
json.item = rpos.item
}
if (rpos.assoc != null) {
json.assoc = rpos.assoc
}
return json
}
/**
* @param {any} json
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock), json.assoc == null ? 0 : json.assoc)
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock))
export class AbsolutePosition {
/**
* @param {AbstractType<any>} type
* @param {number} index
* @param {number} [assoc]
*/
constructor (type, index, assoc = 0) {
constructor (type, index) {
/**
* @type {AbstractType<any>}
*/
@@ -119,27 +84,24 @@ export class AbsolutePosition {
* @type {number}
*/
this.index = index
this.assoc = assoc
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
* @param {number} [assoc]
*
* @function
*/
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
export const createAbsolutePosition = (type, index) => new AbsolutePosition(type, index)
/**
* @param {AbstractType<any>} type
* @param {ID|null} item
* @param {number} [assoc]
*
* @function
*/
export const createRelativePosition = (type, item, assoc) => {
export const createRelativePosition = (type, item) => {
let typeid = null
let tname = null
if (type._item === null) {
@@ -147,7 +109,7 @@ export const createRelativePosition = (type, item, assoc) => {
} else {
typeid = createID(type._item.id.client, type._item.id.clock)
}
return new RelativePosition(typeid, tname, item, assoc)
return new RelativePosition(typeid, tname, item)
}
/**
@@ -155,35 +117,23 @@ export const createRelativePosition = (type, item, assoc) => {
*
* @param {AbstractType<any>} type The base type (e.g. YText or YArray).
* @param {number} index The absolute position.
* @param {number} [assoc]
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
export const createRelativePositionFromTypeIndex = (type, index) => {
let t = type._start
if (assoc < 0) {
// associated to the left character or the beginning of a type, increment index if possible.
if (index === 0) {
return createRelativePosition(type, null, assoc)
}
index--
}
while (t !== null) {
if (!t.deleted && t.countable) {
if (t.length > index) {
// case 1: found position somewhere in the linked list
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
return createRelativePosition(type, createID(t.id.client, t.id.clock + index))
}
index -= t.length
}
if (t.right === null && assoc < 0) {
// left-associated position, return last available id
return createRelativePosition(type, t.lastId, assoc)
}
t = t.right
}
return createRelativePosition(type, null, assoc)
return createRelativePosition(type, null)
}
/**
@@ -193,7 +143,7 @@ export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
* @function
*/
export const writeRelativePosition = (encoder, rpos) => {
const { type, tname, item, assoc } = rpos
const { type, tname, item } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
writeID(encoder, item)
@@ -208,7 +158,6 @@ export const writeRelativePosition = (encoder, rpos) => {
} else {
throw error.unexpectedCase()
}
encoding.writeVarInt(encoder, assoc)
return encoder
}
@@ -224,7 +173,7 @@ export const encodeRelativePosition = rpos => {
/**
* @param {decoding.Decoder} decoder
* @return {RelativePosition}
* @return {RelativePosition|null}
*
* @function
*/
@@ -246,13 +195,12 @@ export const readRelativePosition = decoder => {
type = readID(decoder)
}
}
const assoc = decoding.hasContent(decoder) ? decoding.readVarInt(decoder) : 0
return new RelativePosition(type, tname, itemID, assoc)
return new RelativePosition(type, tname, itemID)
}
/**
* @param {Uint8Array} uint8Array
* @return {RelativePosition}
* @return {RelativePosition|null}
*/
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
@@ -268,7 +216,6 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
const rightID = rpos.item
const typeID = rpos.type
const tname = rpos.tname
const assoc = rpos.assoc
let type = null
let index = 0
if (rightID !== null) {
@@ -282,7 +229,7 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
}
type = /** @type {AbstractType<any>} */ (right.parent)
if (type._item === null || !type._item.deleted) {
index = (right.deleted || !right.countable) ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
index = right.deleted || !right.countable ? 0 : res.diff
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
@@ -309,22 +256,17 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
} else {
throw error.unexpectedCase()
}
if (assoc >= 0) {
index = type._length
} else {
index = 0
}
index = type._length
}
return createAbsolutePosition(type, index, rpos.assoc)
return createAbsolutePosition(type, index)
}
/**
* @param {RelativePosition|null} a
* @param {RelativePosition|null} b
* @return {boolean}
*
* @function
*/
export const compareRelativePositions = (a, b) => a === b || (
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type)
)

View File

@@ -12,16 +12,13 @@ import {
createDeleteSet,
createID,
getState,
findIndexSS,
UpdateEncoderV2,
applyUpdateV2,
DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line
Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as map from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class Snapshot {
/**
@@ -54,12 +51,12 @@ export const equalSnapshots = (snap1, snap2) => {
if (sv1.size !== sv2.size || ds1.size !== ds2.size) {
return false
}
for (const [key, value] of sv1.entries()) {
for (const [key, value] of sv1) {
if (sv2.get(key) !== value) {
return false
}
}
for (const [client, dsitems1] of ds1.entries()) {
for (const [client, dsitems1] of ds1) {
const dsitems2 = ds2.get(client) || []
if (dsitems1.length !== dsitems2.length) {
return false
@@ -77,36 +74,24 @@ export const equalSnapshots = (snap1, snap2) => {
/**
* @param {Snapshot} snapshot
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @return {Uint8Array}
*/
export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
export const encodeSnapshot = snapshot => {
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, snapshot.ds)
writeStateVector(encoder, snapshot.sv)
return encoder.toUint8Array()
return encoding.toUint8Array(encoder)
}
/**
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DSEncoderV1())
/**
* @param {Uint8Array} buf
* @param {DSDecoderV1 | DSDecoderV2} [decoder]
* @return {Snapshot}
*/
export const decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(decoding.createDecoder(buf))) => {
export const decodeSnapshot = buf => {
const decoder = decoding.createDecoder(buf)
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
}
/**
* @param {Uint8Array} buf
* @return {Snapshot}
*/
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new DSDecoderV1(decoding.createDecoder(buf)))
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm
@@ -129,9 +114,9 @@ export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc
* @protected
* @function
*/
export const isVisible = (item, snapshot) => snapshot === undefined
? !item.deleted
: snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
)
/**
* @param {Transaction} transaction
@@ -151,51 +136,3 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
meta.add(snapshot)
}
}
/**
* @param {Doc} originDoc
* @param {Snapshot} snapshot
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
* @return {Doc}
*/
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
if (originDoc.gc) {
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
throw new Error('originDoc must not be garbage collected')
}
const { sv, ds } = snapshot
const encoder = new UpdateEncoderV2()
originDoc.transact(transaction => {
let size = 0
sv.forEach(clock => {
if (clock > 0) {
size++
}
})
encoding.writeVarUint(encoder.restEncoder, size)
// splitting the structs before writing them to the encoder
for (const [client, clock] of sv) {
if (clock === 0) {
continue
}
if (clock < getState(originDoc.store, client)) {
getItemCleanStart(transaction, createID(client, clock))
}
const structs = originDoc.store.clients.get(client) || []
const lastStructIndex = findIndexSS(structs, clock - 1)
// write # encoded structs
encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1)
encoder.writeClient(client)
// first clock written is 0
encoding.writeVarUint(encoder.restEncoder, 0)
for (let i = 0; i <= lastStructIndex; i++) {
structs[i].write(encoder, 0)
}
}
writeDeleteSet(encoder, ds)
})
applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot')
return newDoc
}

View File

@@ -2,11 +2,12 @@
import {
GC,
splitItem,
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line
AbstractStruct, Transaction, ID, Item // eslint-disable-line
} from '../internals.js'
import * as math from 'lib0/math'
import * as error from 'lib0/error'
import * as math from 'lib0/math.js'
import * as error from 'lib0/error.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
export class StructStore {
constructor () {
@@ -15,13 +16,24 @@ export class StructStore {
*/
this.clients = new Map()
/**
* @type {null | { missing: Map<number, number>, update: Uint8Array }}
* Store incompleted struct reads here
* `i` denotes to the next read operation
* We could shift the array of refs instead, but shift is incredible
* slow in Chrome for arrays with more than 100k elements
* @see tryResumePendingStructRefs
* @type {Map<number,{i:number,refs:Array<GC|Item>}>}
*/
this.pendingStructs = null
this.pendingClientsStructRefs = new Map()
/**
* @type {null | Uint8Array}
* Stack of pending structs waiting for struct dependencies
* Maximum length of stack is structReaders.size
* @type {Array<GC|Item>}
*/
this.pendingDs = null
this.pendingStack = []
/**
* @type {Array<decoding.Decoder>}
*/
this.pendingDeleteReaders = []
}
}

View File

@@ -11,14 +11,15 @@ import {
Item,
generateNewClientId,
createID,
UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
GC, StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as set from 'lib0/set'
import * as logging from 'lib0/logging'
import { callAll } from 'lib0/function'
import * as encoding from 'lib0/encoding.js'
import * as map from 'lib0/map.js'
import * as math from 'lib0/math.js'
import * as set from 'lib0/set.js'
import * as logging from 'lib0/logging.js'
import { callAll } from 'lib0/function.js'
/**
* A transaction is created for every change on the Yjs model. It is possible
@@ -75,13 +76,13 @@ export class Transaction {
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
* @type {Map<AbstractType<YEvent<any>>,Set<String|null>>}
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
*/
this.changed = new Map()
/**
* Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`.
* @type {Map<AbstractType<YEvent<any>>,Array<YEvent<any>>>}
* @type {Map<AbstractType<YEvent>,Array<YEvent>>}
*/
this.changedParentTypes = new Map()
/**
@@ -102,34 +103,21 @@ export class Transaction {
* @type {boolean}
*/
this.local = local
/**
* @type {Set<Doc>}
*/
this.subdocsAdded = new Set()
/**
* @type {Set<Doc>}
*/
this.subdocsRemoved = new Set()
/**
* @type {Set<Doc>}
*/
this.subdocsLoaded = new Set()
}
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
* @return {boolean} Whether data was written.
*/
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
export const computeUpdateMessageFromTransaction = transaction => {
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
return false
return null
}
const encoder = encoding.createEncoder()
sortAndMergeDeleteSet(transaction.deleteSet)
writeStructsFromTransaction(encoder, transaction)
writeDeleteSet(encoder, transaction.deleteSet)
return true
return encoder
}
/**
@@ -148,7 +136,7 @@ export const nextID = transaction => {
* did not change, it was just added and we should not fire events for `type`.
*
* @param {Transaction} transaction
* @param {AbstractType<YEvent<any>>} type
* @param {AbstractType<YEvent>} type
* @param {string|null} parentSub
*/
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
@@ -181,7 +169,7 @@ const tryToMergeWithLeft = (structs, pos) => {
* @param {function(Item):boolean} gcFilter
*/
const tryGcDeleteSet = (ds, store, gcFilter) => {
for (const [client, deleteItems] of ds.clients.entries()) {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
@@ -210,7 +198,7 @@ const tryGcDeleteSet = (ds, store, gcFilter) => {
const tryMergeDeleteSet = (ds, store) => {
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
ds.clients.forEach((deleteItems, client) => {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
@@ -224,7 +212,7 @@ const tryMergeDeleteSet = (ds, store) => {
tryToMergeWithLeft(structs, si)
}
}
})
}
}
/**
@@ -284,9 +272,6 @@ const cleanupTransactions = (transactionCleanups, i) => {
.forEach(event => {
event.currentTarget = type
})
// sort events by path length so that top-level events are fired first.
events
.sort((event1, event2) => event1.path.length - event2.path.length)
// We don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
@@ -305,7 +290,7 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryMergeDeleteSet(ds, store)
// on all affected store.clients props, try to merge
transaction.afterState.forEach((clock, client) => {
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
@@ -315,7 +300,7 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryToMergeWithLeft(structs, i)
}
}
})
}
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
@@ -331,42 +316,19 @@ const cleanupTransactions = (transactionCleanups, i) => {
}
}
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
doc.clientID = generateNewClientId()
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const encoder = new UpdateEncoderV1()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
const updateMessage = computeUpdateMessageFromTransaction(transaction)
if (updateMessage !== null) {
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
}
}
if (doc._observers.has('updateV2')) {
const encoder = new UpdateEncoderV2()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
}
}
const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction
if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
subdocsAdded.forEach(subdoc => {
subdoc.clientID = doc.clientID
if (subdoc.collectionid == null) {
subdoc.collectionid = doc.collectionid
}
doc.subdocs.add(subdoc)
})
subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction])
subdocsRemoved.forEach(subdoc => subdoc.destroy())
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
doc.emit('afterAllTransactions', [doc, transactionCleanups])
} else {
cleanupTransactions(transactionCleanups, i + 1)
}
@@ -390,9 +352,6 @@ export const transact = (doc, f, origin = null, local = true) => {
initialCall = true
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
if (transactionCleanups.length === 1) {
doc.emit('beforeAllTransactions', [doc])
}
doc.emit('beforeTransaction', [doc._transaction, doc])
}
try {

View File

@@ -5,44 +5,33 @@ import {
transact,
createID,
redoItem,
iterateStructs,
isParentOf,
followRedone,
getItemCleanStart,
isDeleted,
addToDeleteSet,
Transaction, Doc, Item, GC, DeleteSet, AbstractType, YEvent // eslint-disable-line
getState,
ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js'
import * as time from 'lib0/time'
import * as array from 'lib0/array'
import { Observable } from 'lib0/observable'
import * as time from 'lib0/time.js'
import { Observable } from 'lib0/observable.js'
class StackItem {
/**
* @param {DeleteSet} deletions
* @param {DeleteSet} insertions
* @param {DeleteSet} ds
* @param {Map<number,number>} beforeState
* @param {Map<number,number>} afterState
*/
constructor (deletions, insertions) {
this.insertions = insertions
this.deletions = deletions
constructor (ds, beforeState, afterState) {
this.ds = ds
this.beforeState = beforeState
this.afterState = afterState
/**
* Use this to save and restore metadata like selection range
*/
this.meta = new Map()
}
}
/**
* @param {Transaction} tr
* @param {UndoManager} um
* @param {StackItem} stackItem
*/
const clearUndoManagerStackItem = (tr, um, stackItem) => {
iterateDeletedStructs(tr, stackItem.deletions, item => {
if (item instanceof Item && um.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
/**
* @param {UndoManager} undoManager
@@ -56,11 +45,6 @@ const popStackItem = (undoManager, stack, eventType) => {
* @type {StackItem?}
*/
let result = null
/**
* Keep a reference to the transaction so we can fire the event with the changedParentTypes
* @type {any}
*/
let _tr = null
const doc = undoManager.doc
const scope = undoManager.scope
transact(doc, transaction => {
@@ -76,32 +60,54 @@ const popStackItem = (undoManager, stack, eventType) => {
*/
const itemsToDelete = []
let performedChange = false
iterateDeletedStructs(transaction, stackItem.insertions, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
stackItem.afterState.forEach((endClock, client) => {
const startClock = stackItem.beforeState.get(client) || 0
const len = endClock - startClock
// @todo iterateStructs should not need the structs parameter
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
if (startClock !== endClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
// this must be executed before deleted structs are iterated.
getItemCleanStart(transaction, createID(client, startClock))
if (endClock < getState(doc.store, client)) {
getItemCleanStart(transaction, createID(client, endClock))
}
iterateStructs(transaction, structs, startClock, len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > len) {
getItemCleanStart(transaction, createID(item.id.client, endClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
})
}
})
iterateDeletedStructs(transaction, stackItem.deletions, struct => {
iterateDeletedStructs(transaction, stackItem.ds, struct => {
const id = struct.id
const clock = id.clock
const client = id.client
const startClock = stackItem.beforeState.get(client) || 0
const endClock = stackItem.afterState.get(client) || 0
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
!isDeleted(stackItem.insertions, struct.id)
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(clock >= startClock && clock < endClock)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges) !== null || performedChange
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
@@ -112,20 +118,12 @@ const popStackItem = (undoManager, stack, eventType) => {
performedChange = true
}
}
result = performedChange ? stackItem : null
}
transaction.changed.forEach((subProps, type) => {
// destroy search marker if necessary
if (subProps.has(null) && type._searchMarker) {
type._searchMarker.length = 0
result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
})
_tr = transaction
}
}, undoManager)
if (result != null) {
const changedParentTypes = _tr.changedParentTypes
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType, changedParentTypes }, undoManager])
}
return result
}
@@ -137,7 +135,6 @@ const popStackItem = (undoManager, stack, eventType) => {
* filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
* @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..).
*/
/**
@@ -147,20 +144,19 @@ const popStackItem = (undoManager, stack, eventType) => {
* Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
*
* @extends {Observable<'stack-item-added'|'stack-item-popped'|'stack-cleared'|'stack-item-updated'>}
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
*/
export class UndoManager extends Observable {
/**
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {UndoManagerOptions} options
*/
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]), ignoreRemoteMapChanges = false } = {}) {
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
super()
/**
* @type {Array<AbstractType<any>>}
*/
this.scope = []
this.addToScope(typeScope)
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
this.deleteFilter = deleteFilter
trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins
@@ -181,11 +177,7 @@ export class UndoManager extends Observable {
this.redoing = false
this.doc = /** @type {Doc} */ (this.scope[0].doc)
this.lastChange = 0
this.ignoreRemoteMapChanges = ignoreRemoteMapChanges
/**
* @param {Transaction} transaction
*/
this.afterTransactionHandler = transaction => {
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
// Only track certain transactions
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
return
@@ -197,27 +189,19 @@ export class UndoManager extends Observable {
this.stopCapturing() // next undo should not be appended to last stack item
} else if (!redoing) {
// neither undoing nor redoing: delete redoStack
this.clear(false, true)
this.redoStack = []
}
const insertions = new DeleteSet()
transaction.afterState.forEach((endClock, client) => {
const startClock = transaction.beforeState.get(client) || 0
const len = endClock - startClock
if (len > 0) {
addToDeleteSet(insertions, client, startClock, len)
}
})
const beforeState = transaction.beforeState
const afterState = transaction.afterState
const now = time.getUnixTime()
let didAdd = false
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet])
lastOp.insertions = mergeDeleteSets([lastOp.insertions, insertions])
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.afterState = afterState
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, insertions))
didAdd = true
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
}
if (!undoing && !redoing) {
this.lastChange = now
@@ -228,59 +212,27 @@ export class UndoManager extends Observable {
keepItem(item, true)
}
})
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
if (didAdd) {
this.emit('stack-item-added', changeEvent)
} else {
this.emit('stack-item-updated', changeEvent)
}
}
this.doc.on('afterTransaction', this.afterTransactionHandler)
this.doc.on('destroy', () => {
this.destroy()
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
})
}
/**
* @param {Array<AbstractType<any>> | AbstractType<any>} ytypes
*/
addToScope (ytypes) {
ytypes = array.isArray(ytypes) ? ytypes : [ytypes]
ytypes.forEach(ytype => {
if (this.scope.every(yt => yt !== ytype)) {
this.scope.push(ytype)
clear () {
this.doc.transact(transaction => {
/**
* @param {StackItem} stackItem
*/
const clearItem = stackItem => {
iterateDeletedStructs(transaction, stackItem.ds, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
this.undoStack.forEach(clearItem)
this.redoStack.forEach(clearItem)
})
}
/**
* @param {any} origin
*/
addTrackedOrigin (origin) {
this.trackedOrigins.add(origin)
}
/**
* @param {any} origin
*/
removeTrackedOrigin (origin) {
this.trackedOrigins.delete(origin)
}
clear (clearUndoStack = true, clearRedoStack = true) {
if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
this.doc.transact(tr => {
if (clearUndoStack) {
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.undoStack = []
}
if (clearRedoStack) {
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.redoStack = []
}
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
})
}
this.undoStack = []
this.redoStack = []
}
/**
@@ -338,28 +290,4 @@ export class UndoManager extends Observable {
}
return res
}
/**
* Are undo steps available?
*
* @return {boolean} `true` if undo is possible
*/
canUndo () {
return this.undoStack.length > 0
}
/**
* Are redo steps available?
*
* @return {boolean} `true` if redo is possible
*/
canRedo () {
return this.redoStack.length > 0
}
destroy () {
this.trackedOrigins.delete(this)
this.doc.off('afterTransaction', this.afterTransactionHandler)
super.destroy()
}
}

View File

@@ -1,281 +0,0 @@
import * as buffer from 'lib0/buffer'
import * as decoding from 'lib0/decoding'
import {
ID, createID
} from '../internals.js'
export class DSDecoderV1 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
this.restDecoder = decoder
}
resetDsCurVal () {
// nop
}
/**
* @return {number}
*/
readDsClock () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {number}
*/
readDsLen () {
return decoding.readVarUint(this.restDecoder)
}
}
export class UpdateDecoderV1 extends DSDecoderV1 {
/**
* @return {ID}
*/
readLeftID () {
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
}
/**
* @return {ID}
*/
readRightID () {
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
}
/**
* Read the next client id.
* Use this in favor of readID whenever possible to reduce the number of objects created.
*/
readClient () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readInfo () {
return decoding.readUint8(this.restDecoder)
}
/**
* @return {string}
*/
readString () {
return decoding.readVarString(this.restDecoder)
}
/**
* @return {boolean} isKey
*/
readParentInfo () {
return decoding.readVarUint(this.restDecoder) === 1
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readTypeRef () {
return decoding.readVarUint(this.restDecoder)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @return {number} len
*/
readLen () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {any}
*/
readAny () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {Uint8Array}
*/
readBuf () {
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
}
/**
* Legacy implementation uses JSON parse. We use any-decoding in v2.
*
* @return {any}
*/
readJSON () {
return JSON.parse(decoding.readVarString(this.restDecoder))
}
/**
* @return {string}
*/
readKey () {
return decoding.readVarString(this.restDecoder)
}
}
export class DSDecoderV2 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
/**
* @private
*/
this.dsCurrVal = 0
this.restDecoder = decoder
}
resetDsCurVal () {
this.dsCurrVal = 0
}
/**
* @return {number}
*/
readDsClock () {
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
return this.dsCurrVal
}
/**
* @return {number}
*/
readDsLen () {
const diff = decoding.readVarUint(this.restDecoder) + 1
this.dsCurrVal += diff
return diff
}
}
export class UpdateDecoderV2 extends DSDecoderV2 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
super(decoder)
/**
* List of cached keys. If the keys[id] does not exist, we read a new key
* from stringEncoder and push it to keys.
*
* @type {Array<string>}
*/
this.keys = []
decoding.readVarUint(decoder) // read feature flag - currently unused
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
}
/**
* @return {ID}
*/
readLeftID () {
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
}
/**
* @return {ID}
*/
readRightID () {
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
}
/**
* Read the next client id.
* Use this in favor of readID whenever possible to reduce the number of objects created.
*/
readClient () {
return this.clientDecoder.read()
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readInfo () {
return /** @type {number} */ (this.infoDecoder.read())
}
/**
* @return {string}
*/
readString () {
return this.stringDecoder.read()
}
/**
* @return {boolean}
*/
readParentInfo () {
return this.parentInfoDecoder.read() === 1
}
/**
* @return {number} An unsigned 8-bit integer
*/
readTypeRef () {
return this.typeRefDecoder.read()
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @return {number}
*/
readLen () {
return this.lenDecoder.read()
}
/**
* @return {any}
*/
readAny () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {Uint8Array}
*/
readBuf () {
return decoding.readVarUint8Array(this.restDecoder)
}
/**
* This is mainly here for legacy purposes.
*
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
*
* @return {any}
*/
readJSON () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {string}
*/
readKey () {
const keyClock = this.keyClockDecoder.read()
if (keyClock < this.keys.length) {
return this.keys[keyClock]
} else {
const key = this.stringDecoder.read()
this.keys.push(key)
return key
}
}
}

View File

@@ -1,321 +0,0 @@
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding'
import {
ID // eslint-disable-line
} from '../internals.js'
export class DSEncoderV1 {
constructor () {
this.restEncoder = encoding.createEncoder()
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetDsCurVal () {
// nop
}
/**
* @param {number} clock
*/
writeDsClock (clock) {
encoding.writeVarUint(this.restEncoder, clock)
}
/**
* @param {number} len
*/
writeDsLen (len) {
encoding.writeVarUint(this.restEncoder, len)
}
}
export class UpdateEncoderV1 extends DSEncoderV1 {
/**
* @param {ID} id
*/
writeLeftID (id) {
encoding.writeVarUint(this.restEncoder, id.client)
encoding.writeVarUint(this.restEncoder, id.clock)
}
/**
* @param {ID} id
*/
writeRightID (id) {
encoding.writeVarUint(this.restEncoder, id.client)
encoding.writeVarUint(this.restEncoder, id.clock)
}
/**
* Use writeClient and writeClock instead of writeID if possible.
* @param {number} client
*/
writeClient (client) {
encoding.writeVarUint(this.restEncoder, client)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeInfo (info) {
encoding.writeUint8(this.restEncoder, info)
}
/**
* @param {string} s
*/
writeString (s) {
encoding.writeVarString(this.restEncoder, s)
}
/**
* @param {boolean} isYKey
*/
writeParentInfo (isYKey) {
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeTypeRef (info) {
encoding.writeVarUint(this.restEncoder, info)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @param {number} len
*/
writeLen (len) {
encoding.writeVarUint(this.restEncoder, len)
}
/**
* @param {any} any
*/
writeAny (any) {
encoding.writeAny(this.restEncoder, any)
}
/**
* @param {Uint8Array} buf
*/
writeBuf (buf) {
encoding.writeVarUint8Array(this.restEncoder, buf)
}
/**
* @param {any} embed
*/
writeJSON (embed) {
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
}
/**
* @param {string} key
*/
writeKey (key) {
encoding.writeVarString(this.restEncoder, key)
}
}
export class DSEncoderV2 {
constructor () {
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
this.dsCurrVal = 0
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetDsCurVal () {
this.dsCurrVal = 0
}
/**
* @param {number} clock
*/
writeDsClock (clock) {
const diff = clock - this.dsCurrVal
this.dsCurrVal = clock
encoding.writeVarUint(this.restEncoder, diff)
}
/**
* @param {number} len
*/
writeDsLen (len) {
if (len === 0) {
error.unexpectedCase()
}
encoding.writeVarUint(this.restEncoder, len - 1)
this.dsCurrVal += len
}
}
export class UpdateEncoderV2 extends DSEncoderV2 {
constructor () {
super()
/**
* @type {Map<string,number>}
*/
this.keyMap = new Map()
/**
* Refers to the next uniqe key-identifier to me used.
* See writeKey method for more information.
*
* @type {number}
*/
this.keyClock = 0
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
this.clientEncoder = new encoding.UintOptRleEncoder()
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
this.stringEncoder = new encoding.StringEncoder()
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
this.typeRefEncoder = new encoding.UintOptRleEncoder()
this.lenEncoder = new encoding.UintOptRleEncoder()
}
toUint8Array () {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0) // this is a feature flag that we might use in the future
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
// @note The rest encoder is appended! (note the missing var)
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
return encoding.toUint8Array(encoder)
}
/**
* @param {ID} id
*/
writeLeftID (id) {
this.clientEncoder.write(id.client)
this.leftClockEncoder.write(id.clock)
}
/**
* @param {ID} id
*/
writeRightID (id) {
this.clientEncoder.write(id.client)
this.rightClockEncoder.write(id.clock)
}
/**
* @param {number} client
*/
writeClient (client) {
this.clientEncoder.write(client)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeInfo (info) {
this.infoEncoder.write(info)
}
/**
* @param {string} s
*/
writeString (s) {
this.stringEncoder.write(s)
}
/**
* @param {boolean} isYKey
*/
writeParentInfo (isYKey) {
this.parentInfoEncoder.write(isYKey ? 1 : 0)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeTypeRef (info) {
this.typeRefEncoder.write(info)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @param {number} len
*/
writeLen (len) {
this.lenEncoder.write(len)
}
/**
* @param {any} any
*/
writeAny (any) {
encoding.writeAny(this.restEncoder, any)
}
/**
* @param {Uint8Array} buf
*/
writeBuf (buf) {
encoding.writeVarUint8Array(this.restEncoder, buf)
}
/**
* This is mainly here for legacy purposes.
*
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
*
* @param {any} embed
*/
writeJSON (embed) {
encoding.writeAny(this.restEncoder, embed)
}
/**
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
* occur very often. For a 3d application, the key `position` might occur very often.
*
* We cache these keys in a Map and refer to them via a unique number.
*
* @param {string} key
*/
writeKey (key) {
const clock = this.keyMap.get(key)
if (clock === undefined) {
/**
* @todo uncomment to introduce this feature finally
*
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
* Furthermore, I forgot to set the keyclock. So everything was working fine.
*
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
*
* I don't know yet how to reintroduce this feature..
*
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
*
*/
// this.keyMap.set(key, this.keyClock)
this.keyClockEncoder.write(this.keyClock++)
this.stringEncoder.write(key)
} else {
this.keyClockEncoder.write(clock)
}
}
}

View File

@@ -4,22 +4,21 @@ import {
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set'
import * as array from 'lib0/array'
import * as set from 'lib0/set.js'
import * as array from 'lib0/array.js'
/**
* @template {AbstractType<any>} T
* YEvent describes the changes on a YType.
*/
export class YEvent {
/**
* @param {T} target The changed type.
* @param {AbstractType<any>} target The changed type.
* @param {Transaction} transaction
*/
constructor (target, transaction) {
/**
* The type on which this event was created on.
* @type {T}
* @type {AbstractType<any>}
*/
this.target = target
/**
@@ -36,21 +35,11 @@ export class YEvent {
* @type {Object|null}
*/
this._changes = null
/**
* @type {null | Map<string, { action: 'add' | 'update' | 'delete', oldValue: any, newValue: any }>}
*/
this._keys = null
/**
* @type {null | Array<{ insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any> }>}
*/
this._delta = null
}
/**
* Computes the path from `y` to the changed type.
*
* @todo v14 should standardize on path: Array<{parent, index}> because that is easier to work with.
*
* The following property holds:
* @example
* let type = y
@@ -76,66 +65,6 @@ export class YEvent {
return isDeleted(this.transaction.deleteSet, struct.id)
}
/**
* @type {Map<string, { action: 'add' | 'update' | 'delete', oldValue: any, newValue: any }>}
*/
get keys () {
if (this._keys === null) {
const keys = new Map()
const target = this.target
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
changed.forEach(key => {
if (key !== null) {
const item = /** @type {Item} */ (target._map.get(key))
/**
* @type {'delete' | 'add' | 'update'}
*/
let action
let oldValue
if (this.adds(item)) {
let prev = item.left
while (prev !== null && this.adds(prev)) {
prev = prev.left
}
if (this.deletes(item)) {
if (prev !== null && this.deletes(prev)) {
action = 'delete'
oldValue = array.last(prev.content.getContent())
} else {
return
}
} else {
if (prev !== null && this.deletes(prev)) {
action = 'update'
oldValue = array.last(prev.content.getContent())
} else {
action = 'add'
oldValue = undefined
}
}
} else {
if (this.deletes(item)) {
action = 'delete'
oldValue = array.last(/** @type {Item} */ item.content.getContent())
} else {
return // nop
}
}
keys.set(key, { action, oldValue })
}
})
this._keys = keys
}
return this._keys
}
/**
* @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
*/
get delta () {
return this.changes.delta
}
/**
* Check if a struct is added by this event.
*
@@ -149,7 +78,7 @@ export class YEvent {
}
/**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
*/
get changes () {
let changes = this._changes
@@ -161,11 +90,12 @@ export class YEvent {
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
/**
* @type {Map<string,{ action: 'add' | 'update' | 'delete', oldValue: any}>}
*/
const keys = new Map()
changes = {
added,
deleted,
delta,
keys: this.keys
added, deleted, delta, keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
@@ -209,6 +139,46 @@ export class YEvent {
packOp()
}
}
changed.forEach(key => {
if (key !== null) {
const item = /** @type {Item} */ (target._map.get(key))
/**
* @type {'delete' | 'add' | 'update'}
*/
let action
let oldValue
if (this.adds(item)) {
let prev = item.left
while (prev !== null && this.adds(prev)) {
prev = prev.left
}
if (this.deletes(item)) {
if (prev !== null && this.deletes(prev)) {
action = 'delete'
oldValue = array.last(prev.content.getContent())
} else {
return
}
} else {
if (prev !== null && this.deletes(prev)) {
action = 'update'
oldValue = array.last(prev.content.getContent())
} else {
action = 'add'
oldValue = undefined
}
}
} else {
if (this.deletes(item)) {
action = 'delete'
oldValue = array.last(/** @type {Item} */ item.content.getContent())
} else {
return // nop
}
}
keys.set(key, { action, oldValue })
}
})
this._changes = changes
}
return /** @type {any} */ (changes)

View File

@@ -1,8 +1,7 @@
/**
* @module encoding
*/
/*
*
* We use the first five bits in the info flag for determining the type of the struct.
*
* 0: GC
@@ -17,6 +16,8 @@
import {
findIndexSS,
writeID,
readID,
getState,
createID,
getStateVector,
@@ -24,30 +25,16 @@ import {
writeDeleteSet,
createDeleteSetFromStructStore,
transact,
readItemContent,
UpdateDecoderV1,
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
DSEncoderV2,
DSDecoderV1,
DSEncoderV1,
mergeUpdates,
mergeUpdatesV2,
Skip,
diffUpdateV2,
convertUpdateFormatV2ToV1,
DSDecoderV2, Doc, Transaction, GC, Item, StructStore // eslint-disable-line
readItem,
Doc, Transaction, GC, Item, StructStore, ID // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as binary from 'lib0/binary'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as binary from 'lib0/binary.js'
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {Array<GC|Item>} structs All structs by `client`
* @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)`
@@ -56,12 +43,10 @@ import * as math from 'lib0/math'
*/
const writeStructs = (encoder, structs, client, clock) => {
// write first id
clock = math.max(clock, structs[0].id.clock) // make sure the first id exists
const startNewStructs = findIndexSS(structs, clock)
// write # encoded structs
encoding.writeVarUint(encoder.restEncoder, structs.length - startNewStructs)
encoder.writeClient(client)
encoding.writeVarUint(encoder.restEncoder, clock)
encoding.writeVarUint(encoder, structs.length - startNewStructs)
writeID(encoder, createID(client, clock))
const firstStruct = structs[startNewStructs]
// write first struct with an offset
firstStruct.write(encoder, clock - firstStruct.id.clock)
@@ -71,7 +56,7 @@ const writeStructs = (encoder, structs, client, clock) => {
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {StructStore} store
* @param {Map<number,number>} _sm
*
@@ -93,7 +78,7 @@ export const writeClientsStructs = (encoder, store, _sm) => {
}
})
// write # states that were updated
encoding.writeVarUint(encoder.restEncoder, sm.size)
encoding.writeVarUint(encoder, sm.size)
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
@@ -103,97 +88,31 @@ export const writeClientsStructs = (encoder, store, _sm) => {
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @param {Map<number,Array<GC|Item>>} clientRefs
* @param {Doc} doc
* @return {Map<number, { i: number, refs: Array<Item | GC> }>}
* @return {Map<number,Array<GC|Item>>}
*
* @private
* @function
*/
export const readClientsStructRefs = (decoder, doc) => {
/**
* @type {Map<number, { i: number, refs: Array<Item | GC> }>}
*/
const clientRefs = map.create()
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
const numOfStateUpdates = decoding.readVarUint(decoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
const numberOfStructs = decoding.readVarUint(decoder)
/**
* @type {Array<GC|Item>}
*/
const refs = new Array(numberOfStructs)
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
// const start = performance.now()
clientRefs.set(client, { i: 0, refs })
const refs = []
let { client, clock } = readID(decoder)
let info, struct
clientRefs.set(client, refs)
for (let i = 0; i < numberOfStructs; i++) {
const info = decoder.readInfo()
switch (binary.BITS5 & info) {
case 0: { // GC
const len = decoder.readLen()
refs[i] = new GC(createID(client, clock), len)
clock += len
break
}
case 10: { // Skip Struct (nothing to apply)
// @todo we could reduce the amount of checks by adding Skip struct to clientRefs so we know that something is missing.
const len = decoding.readVarUint(decoder.restDecoder)
refs[i] = new Skip(createID(client, clock), len)
clock += len
break
}
default: { // Item with content
/**
* The optimized implementation doesn't use any variables because inlining variables is faster.
* Below a non-optimized version is shown that implements the basic algorithm with
* a few comments
*/
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const struct = new Item(
createID(client, clock),
null, // leftd
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
null, // right
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
cantCopyParentInfo ? (decoder.readParentInfo() ? doc.get(decoder.readString()) : decoder.readLeftID()) : null, // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
/* A non-optimized implementation of the above algorithm:
// The item that was originally to the left of this item.
const origin = (info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null
// The item that was originally to the right of this item.
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = cantCopyParentInfo ? decoder.readParentInfo() : false
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const parentYKey = cantCopyParentInfo && hasParentYKey ? decoder.readString() : null
const struct = new Item(
createID(client, clock),
null, // leftd
origin, // origin
null, // right
rightOrigin, // right origin
cantCopyParentInfo && !hasParentYKey ? decoder.readLeftID() : (parentYKey !== null ? doc.get(parentYKey) : null), // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
*/
refs[i] = struct
clock += struct.length
}
}
info = decoding.readUint8(decoder)
struct = (binary.BITS5 & info) === 0 ? new GC(createID(client, clock), decoding.readVarUint(decoder)) : readItem(decoder, createID(client, clock), info, doc)
refs.push(struct)
clock += struct.length
}
// console.log('time to read: ', performance.now() - start) // @todo remove
}
return clientRefs
}
@@ -219,148 +138,91 @@ export const readClientsStructRefs = (decoder, doc) => {
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {Map<number, { i: number, refs: (GC | Item)[] }>} clientsStructRefs
* @return { null | { update: Uint8Array, missing: Map<number,number> } }
*
* @private
* @function
*/
const integrateStructs = (transaction, store, clientsStructRefs) => {
/**
* @type {Array<Item | GC>}
*/
const stack = []
const resumeStructIntegration = (transaction, store) => {
const stack = store.pendingStack
const clientsStructRefs = store.pendingClientsStructRefs
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
let clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
if (clientsStructRefsIds.length === 0) {
return null
}
const getNextStructTarget = () => {
if (clientsStructRefsIds.length === 0) {
return null
}
let nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
while (nextStructsTarget.refs.length === nextStructsTarget.i) {
clientsStructRefsIds.pop()
if (clientsStructRefsIds.length > 0) {
nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
} else {
return null
}
}
return nextStructsTarget
}
let curStructsTarget = getNextStructTarget()
if (curStructsTarget === null && stack.length === 0) {
return null
}
/**
* @type {StructStore}
*/
const restStructs = new StructStore()
const missingSV = new Map()
/**
* @param {number} client
* @param {number} clock
*/
const updateMissingSv = (client, clock) => {
const mclock = missingSV.get(client)
if (mclock == null || mclock > clock) {
missingSV.set(client, clock)
}
}
/**
* @type {GC|Item}
*/
let stackHead = /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
// caching the state because it is used very often
const state = new Map()
const addStackToRestSS = () => {
for (const item of stack) {
const client = item.id.client
const unapplicableItems = clientsStructRefs.get(client)
if (unapplicableItems) {
// decrement because we weren't able to apply previous operation
unapplicableItems.i--
restStructs.clients.set(client, unapplicableItems.refs.slice(unapplicableItems.i))
clientsStructRefs.delete(client)
unapplicableItems.i = 0
unapplicableItems.refs = []
} else {
// item was the last item on clientsStructRefs and the field was already cleared. Add item to restStructs and continue
restStructs.clients.set(client, [item])
}
// remove client from clientsStructRefsIds to prevent users from applying the same update again
clientsStructRefsIds = clientsStructRefsIds.filter(c => c !== client)
}
stack.length = 0
}
const clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
let curStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
// iterate over all struct readers until we are done
while (true) {
if (stackHead.constructor !== Skip) {
const localClock = map.setIfUndefined(state, stackHead.id.client, () => getState(store, stackHead.id.client))
const offset = localClock - stackHead.id.clock
if (offset < 0) {
// update from the same client is missing
stack.push(stackHead)
updateMissingSv(stackHead.id.client, stackHead.id.clock - 1)
// hid a dead wall, add all items from stack to restSS
addStackToRestSS()
while (stack.length !== 0 || clientsStructRefsIds.length > 0) {
if (stack.length === 0) {
// take any first struct from clientsStructRefs and put it on the stack
if (curStructsTarget.i < curStructsTarget.refs.length) {
stack.push(curStructsTarget.refs[curStructsTarget.i++])
} else {
const missing = stackHead.getMissing(transaction, store)
if (missing !== null) {
stack.push(stackHead)
// get the struct reader that has the missing struct
/**
* @type {{ refs: Array<GC|Item>, i: number }}
*/
const structRefs = clientsStructRefs.get(/** @type {number} */ (missing)) || { refs: [], i: 0 }
if (structRefs.refs.length === structRefs.i) {
// This update message causally depends on another update message that doesn't exist yet
updateMissingSv(/** @type {number} */ (missing), getState(store, missing))
addStackToRestSS()
} else {
stackHead = structRefs.refs[structRefs.i++]
continue
}
} else if (offset === 0 || offset < stackHead.length) {
// all fine, apply the stackhead
stackHead.integrate(transaction, offset)
state.set(stackHead.id.client, stackHead.id.clock + stackHead.length)
clientsStructRefsIds.pop()
if (clientsStructRefsIds.length > 0) {
curStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
}
continue
}
}
const ref = stack[stack.length - 1]
const refID = ref.id
const client = refID.client
const refClock = refID.clock
const localClock = getState(store, client)
const offset = refClock < localClock ? localClock - refClock : 0
if (refClock + offset !== localClock) {
// A previous message from this client is missing
// check if there is a pending structRef with a smaller clock and switch them
const structRefs = clientsStructRefs.get(client) || { refs: [], i: 0 }
if (structRefs.refs.length !== structRefs.i) {
const r = structRefs.refs[structRefs.i]
if (r.id.clock < refClock) {
// put ref with smaller clock on stack instead and continue
structRefs.refs[structRefs.i] = ref
stack[stack.length - 1] = r
// sort the set because this approach might bring the list out of order
structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.clock - r2.id.clock)
structRefs.i = 0
continue
}
}
// wait until missing struct is available
return
}
// iterate to next stackHead
if (stack.length > 0) {
stackHead = /** @type {GC|Item} */ (stack.pop())
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
} else {
curStructsTarget = getNextStructTarget()
if (curStructsTarget === null) {
// we are done!
break
} else {
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
const missing = ref.getMissing(transaction, store)
if (missing !== null) {
// get the struct reader that has the missing struct
const structRefs = clientsStructRefs.get(missing) || { refs: [], i: 0 }
if (structRefs.refs.length === structRefs.i) {
// This update message causally depends on another update message.
return
}
stack.push(structRefs.refs[structRefs.i++])
} else {
if (offset < ref.length) {
ref.integrate(transaction, offset)
}
stack.pop()
}
}
if (restStructs.clients.size > 0) {
const encoder = new UpdateEncoderV2()
writeClientsStructs(encoder, restStructs, new Map())
// write empty deleteset
// writeDeleteSet(encoder, new DeleteSet())
encoding.writeVarUint(encoder.restEncoder, 0) // => no need for an extra function call, just write 0 deletes
return { missing: missingSV, update: encoder.toUint8Array() }
}
return null
store.pendingClientsStructRefs.clear()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
* @function
*/
export const tryResumePendingDeleteReaders = (transaction, store) => {
const pendingReaders = store.pendingDeleteReaders
store.pendingDeleteReaders = []
for (let i = 0; i < pendingReaders.length; i++) {
readAndApplyDeleteSet(pendingReaders[i], transaction, store)
}
}
/**
* @param {encoding.Encoder} encoder
* @param {Transaction} transaction
*
* @private
@@ -369,116 +231,83 @@ const integrateStructs = (transaction, store, clientsStructRefs) => {
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts an decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* @param {UpdateDecoderV1 | UpdateDecoderV2} [structDecoder]
* @param {StructStore} store
* @param {Map<number, Array<GC|Item>>} clientsStructsRefs
*
* @private
* @function
*/
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
transact(ydoc, transaction => {
// force that transaction.local is set to non-local
transaction.local = false
let retry = false
const doc = transaction.doc
const store = doc.store
// let start = performance.now()
const ss = readClientsStructRefs(structDecoder, doc)
// console.log('time to read structs: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to merge: ', performance.now() - start) // @todo remove
// start = performance.now()
const restStructs = integrateStructs(transaction, store, ss)
const pending = store.pendingStructs
if (pending) {
// check if we can apply something
for (const [client, clock] of pending.missing) {
if (clock < getState(store, client)) {
retry = true
break
}
}
if (restStructs) {
// merge restStructs into store.pending
for (const [client, clock] of restStructs.missing) {
const mclock = pending.missing.get(client)
if (mclock == null || mclock > clock) {
pending.missing.set(client, clock)
}
}
pending.update = mergeUpdatesV2([pending.update, restStructs.update])
}
const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
const pendingClientsStructRefs = store.pendingClientsStructRefs
for (const [client, structRefs] of clientsStructsRefs) {
const pendingStructRefs = pendingClientsStructRefs.get(client)
if (pendingStructRefs === undefined) {
pendingClientsStructRefs.set(client, { refs: structRefs, i: 0 })
} else {
store.pendingStructs = restStructs
}
// console.log('time to integrate: ', performance.now() - start) // @todo remove
// start = performance.now()
const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store)
if (store.pendingDs) {
// @todo we could make a lower-bound state-vector check as we do above
const pendingDSUpdate = new UpdateDecoderV2(decoding.createDecoder(store.pendingDs))
decoding.readVarUint(pendingDSUpdate.restDecoder) // read 0 structs, because we only encode deletes in pendingdsupdate
const dsRest2 = readAndApplyDeleteSet(pendingDSUpdate, transaction, store)
if (dsRest && dsRest2) {
// case 1: ds1 != null && ds2 != null
store.pendingDs = mergeUpdatesV2([dsRest, dsRest2])
} else {
// case 2: ds1 != null
// case 3: ds2 != null
// case 4: ds1 == null && ds2 == null
store.pendingDs = dsRest || dsRest2
// merge into existing structRefs
const merged = pendingStructRefs.i > 0 ? pendingStructRefs.refs.slice(pendingStructRefs.i) : pendingStructRefs.refs
for (let i = 0; i < structRefs.length; i++) {
merged.push(structRefs[i])
}
} else {
// Either dsRest == null && pendingDs == null OR dsRest != null
store.pendingDs = dsRest
pendingStructRefs.i = 0
pendingStructRefs.refs = merged.sort((r1, r2) => r1.id.clock - r2.id.clock)
}
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
// start = performance.now()
if (retry) {
const update = /** @type {{update: Uint8Array}} */ (store.pendingStructs).update
store.pendingStructs = null
applyUpdateV2(transaction.doc, update)
}
}, transactionOrigin, false)
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts an decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new UpdateDecoderV1(decoder))
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
*
* @param {Doc} ydoc
* @param {Uint8Array} update
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
*
* @function
*/
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
const decoder = decoding.createDecoder(update)
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
}
}
/**
* @param {Map<number,{refs:Array<GC|Item>,i:number}>} pendingClientsStructRefs
*/
const cleanupPendingStructs = pendingClientsStructRefs => {
// cleanup pendingClientsStructs if not fully finished
for (const [client, refs] of pendingClientsStructRefs) {
if (refs.i === refs.refs.length) {
pendingClientsStructRefs.delete(client)
} else {
refs.refs.splice(0, refs.i)
refs.i = 0
}
}
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
* @function
*/
export const readStructs = (decoder, transaction, store) => {
const clientsStructRefs = new Map()
readClientsStructRefs(decoder, clientsStructRefs, transaction.doc)
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
resumeStructIntegration(transaction, store)
cleanupPendingStructs(store.pendingClientsStructRefs)
tryResumePendingDeleteReaders(transaction, store)
}
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts an decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
transact(ydoc, transaction => {
readStructs(decoder, transaction, ydoc.store)
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin, false)
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
@@ -490,13 +319,14 @@ export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = Update
*
* @function
*/
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1)
export const applyUpdate = (ydoc, update, transactionOrigin) =>
readUpdate(decoding.createDecoder(update), ydoc, transactionOrigin)
/**
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
* only write the operations that are missing.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {Doc} doc
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
*
@@ -515,60 +345,31 @@ export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map())
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @param {UpdateEncoderV1 | UpdateEncoderV2} [encoder]
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
const targetStateVector = decodeStateVector(encodedTargetStateVector)
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => {
const encoder = encoding.createEncoder()
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
writeStateAsUpdate(encoder, doc, targetStateVector)
const updates = [encoder.toUint8Array()]
// also add the pending updates (if there are any)
if (doc.store.pendingDs) {
updates.push(doc.store.pendingDs)
}
if (doc.store.pendingStructs) {
updates.push(diffUpdateV2(doc.store.pendingStructs.update, encodedTargetStateVector))
}
if (updates.length > 1) {
if (encoder.constructor === UpdateEncoderV1) {
return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update)))
} else if (encoder.constructor === UpdateEncoderV2) {
return mergeUpdatesV2(updates)
}
}
return updates[0]
return encoding.toUint8Array(encoder)
}
/**
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
* only write the operations that are missing.
*
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
/**
* Read state vector from Decoder and return as Map
*
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @param {decoding.Decoder} decoder
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const readStateVector = decoder => {
const ss = new Map()
const ssLength = decoding.readVarUint(decoder.restDecoder)
const ssLength = decoding.readVarUint(decoder)
for (let i = 0; i < ssLength; i++) {
const client = decoding.readVarUint(decoder.restDecoder)
const clock = decoding.readVarUint(decoder.restDecoder)
const client = decoding.readVarUint(decoder)
const clock = decoding.readVarUint(decoder)
ss.set(client, clock)
}
return ss
@@ -582,34 +383,28 @@ export const readStateVector = decoder => {
*
* @function
*/
// export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
/**
* Read decodedState and return State as Map.
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {Uint8Array} decodedState
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1(decoding.createDecoder(decodedState)))
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {encoding.Encoder} encoder
* @param {Map<number,number>} sv
* @function
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder.restEncoder, sv.size)
Array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
encoding.writeVarUint(encoder.restEncoder, clock)
encoding.writeVarUint(encoder, sv.size)
sv.forEach((clock, client) => {
encoding.writeVarUint(encoder, client)
encoding.writeVarUint(encoder, clock)
})
return encoder
}
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Doc} doc
*
* @function
@@ -619,27 +414,13 @@ export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encod
/**
* Encode State as Uint8Array.
*
* @param {Doc|Map<number,number>} doc
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @param {Doc} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVectorV2 = (doc, encoder = new DSEncoderV2()) => {
if (doc instanceof Map) {
writeStateVector(encoder, doc)
} else {
writeDocumentStateVector(encoder, doc)
}
return encoder.toUint8Array()
export const encodeStateVector = doc => {
const encoder = encoding.createEncoder()
writeDocumentStateVector(encoder, doc)
return encoding.toUint8Array(encoder)
}
/**
* Encode State as Uint8Array.
*
* @param {Doc|Map<number,number>} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DSEncoderV1())

View File

@@ -1,22 +0,0 @@
import {
AbstractType // eslint-disable-line
} from '../internals.js'
/**
* Convenient helper to log type information.
*
* Do not use in productive systems as the output can be immense!
*
* @param {AbstractType<any>} type
*/
export const logType = type => {
const res = []
let n = type._start
while (n) {
res.push(n)
n = n.right
}
console.log('Children: ', res)
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
}

View File

@@ -1,581 +0,0 @@
import * as binary from 'lib0/binary'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as logging from 'lib0/logging'
import * as math from 'lib0/math'
import {
createID,
readItemContent,
readDeleteSet,
writeDeleteSet,
Skip,
mergeDeleteSets,
DSEncoderV1,
DSEncoderV2,
decodeStateVector,
Item, GC, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line
} from '../internals.js'
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
*/
function * lazyStructReaderGenerator (decoder) {
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numberOfStructs; i++) {
const info = decoder.readInfo()
// @todo use switch instead of ifs
if (info === 10) {
const len = decoding.readVarUint(decoder.restDecoder)
yield new Skip(createID(client, clock), len)
clock += len
} else if ((binary.BITS5 & info) !== 0) {
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const struct = new Item(
createID(client, clock),
null, // left
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
null, // right
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
// @ts-ignore Force writing a string here.
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
yield struct
clock += struct.length
} else {
const len = decoder.readLen()
yield new GC(createID(client, clock), len)
clock += len
}
}
}
}
export class LazyStructReader {
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {boolean} filterSkips
*/
constructor (decoder, filterSkips) {
this.gen = lazyStructReaderGenerator(decoder)
/**
* @type {null | Item | Skip | GC}
*/
this.curr = null
this.done = false
this.filterSkips = filterSkips
this.next()
}
/**
* @return {Item | GC | Skip |null}
*/
next () {
// ignore "Skip" structs
do {
this.curr = this.gen.next().value || null
} while (this.filterSkips && this.curr !== null && this.curr.constructor === Skip)
return this.curr
}
}
/**
* @param {Uint8Array} update
*
*/
export const logUpdate = update => logUpdateV2(update, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*
*/
export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
}
logging.print('Structs: ', structs)
const ds = readDeleteSet(updateDecoder)
logging.print('DeleteSet: ', ds)
}
/**
* @param {Uint8Array} update
*
*/
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*
*/
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
}
return {
structs,
ds: readDeleteSet(updateDecoder)
}
}
export class LazyStructWriter {
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
constructor (encoder) {
this.currClient = 0
this.startClock = 0
this.written = 0
this.encoder = encoder
/**
* We want to write operations lazily, but also we need to know beforehand how many operations we want to write for each client.
*
* This kind of meta-information (#clients, #structs-per-client-written) is written to the restEncoder.
*
* We fragment the restEncoder and store a slice of it per-client until we know how many clients there are.
* When we flush (toUint8Array) we write the restEncoder using the fragments and the meta-information.
*
* @type {Array<{ written: number, restEncoder: Uint8Array }>}
*/
this.clientStructs = []
}
}
/**
* @param {Array<Uint8Array>} updates
* @return {Uint8Array}
*/
export const mergeUpdates = updates => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {Uint8Array} update
* @param {typeof DSEncoderV1 | typeof DSEncoderV2} YEncoder
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
* @return {Uint8Array}
*/
export const encodeStateVectorFromUpdateV2 = (update, YEncoder = DSEncoderV2, YDecoder = UpdateDecoderV2) => {
const encoder = new YEncoder()
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
let curr = updateDecoder.curr
if (curr !== null) {
let size = 0
let currClient = curr.id.client
let stopCounting = curr.id.clock !== 0 // must start at 0
let currClock = stopCounting ? 0 : curr.id.clock + curr.length
for (; curr !== null; curr = updateDecoder.next()) {
if (currClient !== curr.id.client) {
if (currClock !== 0) {
size++
// We found a new client
// write what we have to the encoder
encoding.writeVarUint(encoder.restEncoder, currClient)
encoding.writeVarUint(encoder.restEncoder, currClock)
}
currClient = curr.id.client
currClock = 0
stopCounting = curr.id.clock !== 0
}
// we ignore skips
if (curr.constructor === Skip) {
stopCounting = true
}
if (!stopCounting) {
currClock = curr.id.clock + curr.length
}
}
// write what we have
if (currClock !== 0) {
size++
encoding.writeVarUint(encoder.restEncoder, currClient)
encoding.writeVarUint(encoder.restEncoder, currClock)
}
// prepend the size of the state vector
const enc = encoding.createEncoder()
encoding.writeVarUint(enc, size)
encoding.writeBinaryEncoder(enc, encoder.restEncoder)
encoder.restEncoder = enc
return encoder.toUint8Array()
} else {
encoding.writeVarUint(encoder.restEncoder, 0)
return encoder.toUint8Array()
}
}
/**
* @param {Uint8Array} update
* @return {Uint8Array}
*/
export const encodeStateVectorFromUpdate = update => encodeStateVectorFromUpdateV2(update, DSEncoderV1, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
* @return {{ from: Map<number,number>, to: Map<number,number> }}
*/
export const parseUpdateMetaV2 = (update, YDecoder = UpdateDecoderV2) => {
/**
* @type {Map<number, number>}
*/
const from = new Map()
/**
* @type {Map<number, number>}
*/
const to = new Map()
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
let curr = updateDecoder.curr
if (curr !== null) {
let currClient = curr.id.client
let currClock = curr.id.clock
// write the beginning to `from`
from.set(currClient, currClock)
for (; curr !== null; curr = updateDecoder.next()) {
if (currClient !== curr.id.client) {
// We found a new client
// write the end to `to`
to.set(currClient, currClock)
// write the beginning to `from`
from.set(curr.id.client, curr.id.clock)
// update currClient
currClient = curr.id.client
}
currClock = curr.id.clock + curr.length
}
// write the end to `to`
to.set(currClient, currClock)
}
return { from, to }
}
/**
* @param {Uint8Array} update
* @return {{ from: Map<number,number>, to: Map<number,number> }}
*/
export const parseUpdateMeta = update => parseUpdateMetaV2(update, UpdateDecoderV1)
/**
* This method is intended to slice any kind of struct and retrieve the right part.
* It does not handle side-effects, so it should only be used by the lazy-encoder.
*
* @param {Item | GC | Skip} left
* @param {number} diff
* @return {Item | GC}
*/
const sliceStruct = (left, diff) => {
if (left.constructor === GC) {
const { client, clock } = left.id
return new GC(createID(client, clock + diff), left.length - diff)
} else if (left.constructor === Skip) {
const { client, clock } = left.id
return new Skip(createID(client, clock + diff), left.length - diff)
} else {
const leftItem = /** @type {Item} */ (left)
const { client, clock } = leftItem.id
return new Item(
createID(client, clock + diff),
null,
createID(client, clock + diff - 1),
null,
leftItem.rightOrigin,
leftItem.parent,
leftItem.parentSub,
leftItem.content.splice(diff)
)
}
}
/**
*
* This function works similarly to `readUpdateV2`.
*
* @param {Array<Uint8Array>} updates
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
* @return {Uint8Array}
*/
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
if (updates.length === 1) {
return updates[0]
}
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
let lazyStructDecoders = updateDecoders.map(decoder => new LazyStructReader(decoder, true))
/**
* @todo we don't need offset because we always slice before
* @type {null | { struct: Item | GC | Skip, offset: number }}
*/
let currWrite = null
const updateEncoder = new YEncoder()
// write structs lazily
const lazyStructEncoder = new LazyStructWriter(updateEncoder)
// Note: We need to ensure that all lazyStructDecoders are fully consumed
// Note: Should merge document updates whenever possible - even from different updates
// Note: Should handle that some operations cannot be applied yet ()
while (true) {
// Write higher clients first ⇒ sort by clientID & clock and remove decoders without content
lazyStructDecoders = lazyStructDecoders.filter(dec => dec.curr !== null)
lazyStructDecoders.sort(
/** @type {function(any,any):number} */ (dec1, dec2) => {
if (dec1.curr.id.client === dec2.curr.id.client) {
const clockDiff = dec1.curr.id.clock - dec2.curr.id.clock
if (clockDiff === 0) {
// @todo remove references to skip since the structDecoders must filter Skips.
return dec1.curr.constructor === dec2.curr.constructor
? 0
: dec1.curr.constructor === Skip ? 1 : -1 // we are filtering skips anyway.
} else {
return clockDiff
}
} else {
return dec2.curr.id.client - dec1.curr.id.client
}
}
)
if (lazyStructDecoders.length === 0) {
break
}
const currDecoder = lazyStructDecoders[0]
// write from currDecoder until the next operation is from another client or if filler-struct
// then we need to reorder the decoders and find the next operation to write
const firstClient = /** @type {Item | GC} */ (currDecoder.curr).id.client
if (currWrite !== null) {
let curr = /** @type {Item | GC | null} */ (currDecoder.curr)
let iterated = false
// iterate until we find something that we haven't written already
// remember: first the high client-ids are written
while (curr !== null && curr.id.clock + curr.length <= currWrite.struct.id.clock + currWrite.struct.length && curr.id.client >= currWrite.struct.id.client) {
curr = currDecoder.next()
iterated = true
}
if (
curr === null || // current decoder is empty
curr.id.client !== firstClient || // check whether there is another decoder that has has updates from `firstClient`
(iterated && curr.id.clock > currWrite.struct.id.clock + currWrite.struct.length) // the above while loop was used and we are potentially missing updates
) {
continue
}
if (firstClient !== currWrite.struct.id.client) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: curr, offset: 0 }
currDecoder.next()
} else {
if (currWrite.struct.id.clock + currWrite.struct.length < curr.id.clock) {
// @todo write currStruct & set currStruct = Skip(clock = currStruct.id.clock + currStruct.length, length = curr.id.clock - self.clock)
if (currWrite.struct.constructor === Skip) {
// extend existing skip
currWrite.struct.length = curr.id.clock + curr.length - currWrite.struct.id.clock
} else {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
const diff = curr.id.clock - currWrite.struct.id.clock - currWrite.struct.length
/**
* @type {Skip}
*/
const struct = new Skip(createID(firstClient, currWrite.struct.id.clock + currWrite.struct.length), diff)
currWrite = { struct, offset: 0 }
}
} else { // if (currWrite.struct.id.clock + currWrite.struct.length >= curr.id.clock) {
const diff = currWrite.struct.id.clock + currWrite.struct.length - curr.id.clock
if (diff > 0) {
if (currWrite.struct.constructor === Skip) {
// prefer to slice Skip because the other struct might contain more information
currWrite.struct.length -= diff
} else {
curr = sliceStruct(curr, diff)
}
}
if (!currWrite.struct.mergeWith(/** @type {any} */ (curr))) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: curr, offset: 0 }
currDecoder.next()
}
}
}
} else {
currWrite = { struct: /** @type {Item | GC} */ (currDecoder.curr), offset: 0 }
currDecoder.next()
}
for (
let next = currDecoder.curr;
next !== null && next.id.client === firstClient && next.id.clock === currWrite.struct.id.clock + currWrite.struct.length && next.constructor !== Skip;
next = currDecoder.next()
) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: next, offset: 0 }
}
}
if (currWrite !== null) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = null
}
finishLazyStructWriting(lazyStructEncoder)
const dss = updateDecoders.map(decoder => readDeleteSet(decoder))
const ds = mergeDeleteSets(dss)
writeDeleteSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
*/
export const diffUpdateV2 = (update, sv, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
const state = decodeStateVector(sv)
const encoder = new YEncoder()
const lazyStructWriter = new LazyStructWriter(encoder)
const decoder = new YDecoder(decoding.createDecoder(update))
const reader = new LazyStructReader(decoder, false)
while (reader.curr) {
const curr = reader.curr
const currClient = curr.id.client
const svClock = state.get(currClient) || 0
if (reader.curr.constructor === Skip) {
// the first written struct shouldn't be a skip
reader.next()
continue
}
if (curr.id.clock + curr.length > svClock) {
writeStructToLazyStructWriter(lazyStructWriter, curr, math.max(svClock - curr.id.clock, 0))
reader.next()
while (reader.curr && reader.curr.id.client === currClient) {
writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0)
reader.next()
}
} else {
// read until something new comes up
while (reader.curr && reader.curr.id.client === currClient && reader.curr.id.clock + reader.curr.length <= svClock) {
reader.next()
}
}
}
finishLazyStructWriting(lazyStructWriter)
// write ds
const ds = readDeleteSet(decoder)
writeDeleteSet(encoder, ds)
return encoder.toUint8Array()
}
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
*/
export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {LazyStructWriter} lazyWriter
*/
const flushLazyStructWriter = lazyWriter => {
if (lazyWriter.written > 0) {
lazyWriter.clientStructs.push({ written: lazyWriter.written, restEncoder: encoding.toUint8Array(lazyWriter.encoder.restEncoder) })
lazyWriter.encoder.restEncoder = encoding.createEncoder()
lazyWriter.written = 0
}
}
/**
* @param {LazyStructWriter} lazyWriter
* @param {Item | GC} struct
* @param {number} offset
*/
const writeStructToLazyStructWriter = (lazyWriter, struct, offset) => {
// flush curr if we start another client
if (lazyWriter.written > 0 && lazyWriter.currClient !== struct.id.client) {
flushLazyStructWriter(lazyWriter)
}
if (lazyWriter.written === 0) {
lazyWriter.currClient = struct.id.client
// write next client
lazyWriter.encoder.writeClient(struct.id.client)
// write startClock
encoding.writeVarUint(lazyWriter.encoder.restEncoder, struct.id.clock + offset)
}
struct.write(lazyWriter.encoder, offset)
lazyWriter.written++
}
/**
* Call this function when we collected all parts and want to
* put all the parts together. After calling this method,
* you can continue using the UpdateEncoder.
*
* @param {LazyStructWriter} lazyWriter
*/
const finishLazyStructWriting = (lazyWriter) => {
flushLazyStructWriter(lazyWriter)
// this is a fresh encoder because we called flushCurr
const restEncoder = lazyWriter.encoder.restEncoder
/**
* Now we put all the fragments together.
* This works similarly to `writeClientsStructs`
*/
// write # states that were updated - i.e. the clients
encoding.writeVarUint(restEncoder, lazyWriter.clientStructs.length)
for (let i = 0; i < lazyWriter.clientStructs.length; i++) {
const partStructs = lazyWriter.clientStructs[i]
/**
* Works similarly to `writeStructs`
*/
// write # encoded structs
encoding.writeVarUint(restEncoder, partStructs.written)
// write the rest of the fragment
encoding.writeUint8Array(restEncoder, partStructs.restEncoder)
}
}
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
*/
export const convertUpdateFormat = (update, YDecoder, YEncoder) => {
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
const updateEncoder = new YEncoder()
const lazyWriter = new LazyStructWriter(updateEncoder)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
writeStructToLazyStructWriter(lazyWriter, curr, 0)
}
finishLazyStructWriting(lazyWriter)
const ds = readDeleteSet(updateDecoder)
writeDeleteSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, UpdateDecoderV2, UpdateEncoderV1)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
/**
* Client id should be changed when an instance receives updates from another client using the same client id.
*
* @param {t.TestCase} tc
*/
export const testClientIdDuplicateChange = tc => {
const doc1 = new Y.Doc()
doc1.clientID = 0
const doc2 = new Y.Doc()
doc2.clientID = 0
t.assert(doc2.clientID === doc1.clientID)
doc1.getArray('a').insert(0, [1, 2])
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.clientID !== doc1.clientID)
}

View File

@@ -1,246 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
/**
* Client id should be changed when an instance receives updates from another client using the same client id.
*
* @param {t.TestCase} tc
*/
export const testClientIdDuplicateChange = tc => {
const doc1 = new Y.Doc()
doc1.clientID = 0
const doc2 = new Y.Doc()
doc2.clientID = 0
t.assert(doc2.clientID === doc1.clientID)
doc1.getArray('a').insert(0, [1, 2])
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.clientID !== doc1.clientID)
}
/**
* @param {t.TestCase} tc
*/
export const testGetTypeEmptyId = tc => {
const doc1 = new Y.Doc()
doc1.getText('').insert(0, 'h')
doc1.getText().insert(1, 'i')
const doc2 = new Y.Doc()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.getText().toString() === 'hi')
t.assert(doc2.getText('').toString() === 'hi')
}
/**
* @param {t.TestCase} tc
*/
export const testToJSON = tc => {
const doc = new Y.Doc()
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
const arr = doc.getArray('array')
arr.push(['test1'])
const map = doc.getMap('map')
map.set('k1', 'v1')
const map2 = new Y.Map()
map.set('k2', map2)
map2.set('m2k1', 'm2v1')
t.compare(doc.toJSON(), {
array: ['test1'],
map: {
k1: 'v1',
k2: {
m2k1: 'm2v1'
}
}
}, 'doc.toJSON has array and recursive map')
}
/**
* @param {t.TestCase} tc
*/
export const testSubdoc = tc => {
const doc = new Y.Doc()
doc.load() // doesn't do anything
{
/**
* @type {Array<any>|null}
*/
let event = /** @type {any} */ (null)
doc.on('subdocs', subdocs => {
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
})
const subdocs = doc.getMap('mysubdocs')
const docA = new Y.Doc({ guid: 'a' })
docA.load()
subdocs.set('a', docA)
t.compare(event, [['a'], [], ['a']])
event = null
subdocs.get('a').load()
t.assert(event === null)
event = null
subdocs.get('a').destroy()
t.compare(event, [['a'], ['a'], []])
subdocs.get('a').load()
t.compare(event, [[], [], ['a']])
subdocs.set('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
t.compare(event, [['a'], [], []])
subdocs.get('b').load()
t.compare(event, [[], [], ['a']])
const docC = new Y.Doc({ guid: 'c' })
docC.load()
subdocs.set('c', docC)
t.compare(event, [['c'], [], ['c']])
t.compare(Array.from(doc.getSubdocGuids()), ['a', 'c'])
}
const doc2 = new Y.Doc()
{
t.compare(Array.from(doc2.getSubdocs()), [])
/**
* @type {Array<any>|null}
*/
let event = /** @type {any} */ (null)
doc2.on('subdocs', subdocs => {
event = [Array.from(subdocs.added).map(d => d.guid), Array.from(subdocs.removed).map(d => d.guid), Array.from(subdocs.loaded).map(d => d.guid)]
})
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
t.compare(event, [['a', 'a', 'c'], [], []])
doc2.getMap('mysubdocs').get('a').load()
t.compare(event, [[], [], ['a']])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
doc2.getMap('mysubdocs').delete('a')
t.compare(event, [[], ['a'], []])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
}
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocLoadEdgeCases = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc()
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc3.shouldLoad === false)
t.assert(subdoc3.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
// load
subdoc3.load()
t.assert(subdoc3.shouldLoad)
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocLoadEdgeCasesAutoload = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc({ autoLoad: true })
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocsUndo = tc => {
const ydoc = new Y.Doc()
const elems = ydoc.getXmlFragment()
const undoManager = new Y.UndoManager(elems)
const subdoc = new Y.Doc()
// @ts-ignore
elems.insert(0, [subdoc])
undoManager.undo()
undoManager.redo()
t.assert(elems.length === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testLoadDocs = async tc => {
const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false)
let loadedEvent = false
ydoc.on('load', () => {
loadedEvent = true
})
ydoc.emit('load', [ydoc])
await ydoc.whenLoaded
t.assert(loadedEvent)
t.assert(ydoc.isLoaded)
}

View File

@@ -1,5 +1,4 @@
import * as t from 'lib0/testing'
import * as promise from 'lib0/promise'
import * as t from 'lib0/testing.js'
import {
contentRefs,
@@ -10,21 +9,14 @@ import {
readContentEmbed,
readContentType,
readContentFormat,
readContentAny,
readContentDoc,
Doc,
PermanentUserData,
encodeStateAsUpdate,
applyUpdate
readContentAny
} from '../src/internals.js'
import * as Y from '../src/index.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 11)
t.assert(contentRefs.length === 9)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
@@ -33,76 +25,4 @@ export const testStructReferences = tc => {
t.assert(contentRefs[6] === readContentFormat)
t.assert(contentRefs[7] === readContentType)
t.assert(contentRefs[8] === readContentAny)
t.assert(contentRefs[9] === readContentDoc)
// contentRefs[10] is reserved for Skip structs
}
/**
* There is some custom encoding/decoding happening in PermanentUserData.
* This is why it landed here.
*
* @param {t.TestCase} tc
*/
export const testPermanentUserData = async tc => {
const ydoc1 = new Doc()
const ydoc2 = new Doc()
const pd1 = new PermanentUserData(ydoc1)
const pd2 = new PermanentUserData(ydoc2)
pd1.setUserMapping(ydoc1, ydoc1.clientID, 'user a')
pd2.setUserMapping(ydoc2, ydoc2.clientID, 'user b')
ydoc1.getText().insert(0, 'xhi')
ydoc1.getText().delete(0, 1)
ydoc2.getText().insert(0, 'hxxi')
ydoc2.getText().delete(1, 2)
await promise.wait(10)
applyUpdate(ydoc2, encodeStateAsUpdate(ydoc1))
applyUpdate(ydoc1, encodeStateAsUpdate(ydoc2))
// now sync a third doc with same name as doc1 and then create PermanentUserData
const ydoc3 = new Doc()
applyUpdate(ydoc3, encodeStateAsUpdate(ydoc1))
const pd3 = new PermanentUserData(ydoc3)
pd3.setUserMapping(ydoc3, ydoc3.clientID, 'user a')
}
/**
* Reported here: https://github.com/yjs/yjs/issues/308
* @param {t.TestCase} tc
*/
export const testDiffStateVectorOfUpdateIsEmpty = tc => {
const ydoc = new Y.Doc()
/**
* @type {any}
*/
let sv = null
ydoc.getText().insert(0, 'a')
ydoc.on('update', update => {
sv = Y.encodeStateVectorFromUpdate(update)
})
// should produce an update with an empty state vector (because previous ops are missing)
ydoc.getText().insert(0, 'a')
t.assert(sv !== null && sv.byteLength === 1 && sv[0] === 0)
}
/**
* Reported here: https://github.com/yjs/yjs/issues/308
* @param {t.TestCase} tc
*/
export const testDiffStateVectorOfUpdateIgnoresSkips = tc => {
const ydoc = new Y.Doc()
/**
* @type {Array<Uint8Array>}
*/
const updates = []
ydoc.on('update', update => {
updates.push(update)
})
ydoc.getText().insert(0, 'a')
ydoc.getText().insert(0, 'b')
ydoc.getText().insert(0, 'c')
const update13 = Y.mergeUpdates([updates[0], updates[2]])
const sv = Y.encodeStateVectorFromUpdate(update13)
const state = Y.decodeStateVector(sv)
t.assert(state.get(ydoc.clientID) === 1)
t.assert(state.size === 1)
}

View File

@@ -1,25 +1,21 @@
import * as map from './y-map.tests.js'
import * as array from './y-array.tests.js'
import * as map from './y-map.tests.js'
import * as text from './y-text.tests.js'
import * as xml from './y-xml.tests.js'
import * as encoding from './encoding.tests.js'
import * as undoredo from './undo-redo.tests.js'
import * as compatibility from './compatibility.tests.js'
import * as doc from './doc.tests.js'
import * as snapshot from './snapshot.tests.js'
import * as updates from './updates.tests.js'
import * as relativePositions from './relativePositions.tests.js'
import * as consistency from './consistency.tests.js'
import { runTests } from 'lib0/testing'
import { isBrowser, isNode } from 'lib0/environment'
import * as log from 'lib0/logging'
import { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js'
import * as log from 'lib0/logging.js'
if (isBrowser) {
log.createVConsole(document.body)
}
runTests({
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions
map, array, text, xml, consistency, encoding, undoredo
}).then(success => {
/* istanbul ignore next */
if (isNode) {

View File

@@ -1,104 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
/**
* @param {Y.Text} ytext
*/
const checkRelativePositions = ytext => {
// test if all positions are encoded and restored correctly
for (let i = 0; i < ytext.length; i++) {
// for all types of associations..
for (let assoc = -1; assoc < 2; assoc++) {
const rpos = Y.createRelativePositionFromTypeIndex(ytext, i, assoc)
const encodedRpos = Y.encodeRelativePosition(rpos)
const decodedRpos = Y.decodeRelativePosition(encodedRpos)
const absPos = /** @type {Y.AbsolutePosition} */ (Y.createAbsolutePositionFromRelativePosition(decodedRpos, /** @type {Y.Doc} */ (ytext.doc)))
t.assert(absPos.index === i)
t.assert(absPos.assoc === assoc)
}
}
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase1 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '1')
ytext.insert(0, 'abc')
ytext.insert(0, 'z')
ytext.insert(0, 'y')
ytext.insert(0, 'x')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase2 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'abc')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase3 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'abc')
ytext.insert(0, '1')
ytext.insert(0, 'xyz')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase4 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '1')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase5 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '2')
ytext.insert(0, '1')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase6 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionAssociationDifference = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '2')
ytext.insert(0, '1')
const rposRight = Y.createRelativePositionFromTypeIndex(ytext, 1, 0)
const rposLeft = Y.createRelativePositionFromTypeIndex(ytext, 1, -1)
ytext.insert(1, 'x')
const posRight = Y.createAbsolutePositionFromRelativePosition(rposRight, ydoc)
const posLeft = Y.createAbsolutePositionFromRelativePosition(rposLeft, ydoc)
t.assert(posRight != null && posRight.index === 2)
t.assert(posLeft != null && posLeft.index === 1)
}

View File

@@ -1,171 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper.js'
/**
* @param {t.TestCase} tc
*/
export const testBasicRestoreSnapshot = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['hello'])
const snap = Y.snapshot(doc)
doc.getArray('array').insert(1, ['world'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['hello'])
t.compare(doc.getArray('array').toArray(), ['hello', 'world'])
}
/**
* @param {t.TestCase} tc
*/
export const testEmptyRestoreSnapshot = tc => {
const doc = new Y.Doc({ gc: false })
const snap = Y.snapshot(doc)
snap.sv.set(9999, 0)
doc.getArray().insert(0, ['world'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray().toArray(), [])
t.compare(doc.getArray().toArray(), ['world'])
// now this snapshot reflects the latest state. It shoult still work.
const snap2 = Y.snapshot(doc)
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
t.compare(docRestored2.getArray().toArray(), ['world'])
}
/**
* @param {t.TestCase} tc
*/
export const testRestoreSnapshotWithSubType = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, [new Y.Map()])
const subMap = doc.getArray('array').get(0)
subMap.set('key1', 'value1')
const snap = Y.snapshot(doc)
subMap.set('key2', 'value2')
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toJSON(), [{
key1: 'value1'
}])
t.compare(doc.getArray('array').toJSON(), [{
key1: 'value1',
key2: 'value2'
}])
}
/**
* @param {t.TestCase} tc
*/
export const testRestoreDeletedItem1 = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2'])
const snap = Y.snapshot(doc)
doc.getArray('array').delete(0)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2'])
t.compare(doc.getArray('array').toArray(), ['item2'])
}
/**
* @param {t.TestCase} tc
*/
export const testRestoreLeftItem = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getMap('map').set('test', 1)
doc.getArray('array').insert(0, ['item0'])
const snap = Y.snapshot(doc)
doc.getArray('array').delete(1)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1'])
t.compare(doc.getArray('array').toArray(), ['item0'])
}
/**
* @param {t.TestCase} tc
*/
export const testDeletedItemsBase = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getArray('array').delete(0)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), [])
t.compare(doc.getArray('array').toArray(), ['item0'])
}
/**
* @param {t.TestCase} tc
*/
export const testDeletedItems2 = tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
doc.getArray('array').delete(1)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3'])
t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3'])
}
/**
* @param {t.TestCase} tc
*/
export const testDependentChanges = tc => {
const { array0, array1, testConnector } = init(tc, { users: 2 })
if (!array0.doc) {
throw new Error('no document 0')
}
if (!array1.doc) {
throw new Error('no document 1')
}
/**
* @type {Y.Doc}
*/
const doc0 = array0.doc
/**
* @type {Y.Doc}
*/
const doc1 = array1.doc
doc0.gc = false
doc1.gc = false
array0.insert(0, ['user1item1'])
testConnector.syncAll()
array1.insert(1, ['user2item1'])
testConnector.syncAll()
const snap = Y.snapshot(array0.doc)
array0.insert(2, ['user1item2'])
testConnector.syncAll()
array1.insert(3, ['user2item2'])
testConnector.syncAll()
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
}

View File

@@ -1,18 +1,19 @@
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as syncProtocol from 'y-protocols/sync'
import * as object from 'lib0/object'
import * as map from 'lib0/map'
import * as Y from '../src/index.js'
export * from '../src/index.js'
if (typeof window !== 'undefined') {
// @ts-ignore
window.Y = Y // eslint-disable-line
}
import {
createDeleteSetFromStructStore,
getStateVector,
Item,
DeleteItem, DeleteSet, StructStore, Doc // eslint-disable-line
} from '../src/internals.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as syncProtocol from 'y-protocols/sync.js'
import * as object from 'lib0/object.js'
export * from '../src/internals.js'
/**
* @param {TestYInstance} y // publish message created by `y` to all other online clients
@@ -28,40 +29,7 @@ const broadcastMessage = (y, m) => {
}
}
export let useV2 = false
export const encV1 = {
encodeStateAsUpdate: Y.encodeStateAsUpdate,
mergeUpdates: Y.mergeUpdates,
applyUpdate: Y.applyUpdate,
logUpdate: Y.logUpdate,
updateEventName: 'update',
diffUpdate: Y.diffUpdate
}
export const encV2 = {
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
mergeUpdates: Y.mergeUpdatesV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
updateEventName: 'updateV2',
diffUpdate: Y.diffUpdateV2
}
export let enc = encV1
const useV1Encoding = () => {
useV2 = false
enc = encV1
}
const useV2Encoding = () => {
console.error('sync protocol doesnt support v2 protocol yet, fallback to v1 encoding') // @Todo
useV2 = false
enc = encV1
}
export class TestYInstance extends Y.Doc {
export class TestYInstance extends Doc {
/**
* @param {TestConnector} testConnector
* @param {number} clientID
@@ -78,20 +46,13 @@ export class TestYInstance extends Y.Doc {
*/
this.receiving = new Map()
testConnector.allConns.add(this)
/**
* The list of received updates.
* We are going to merge them later using Y.mergeUpdates and check if the resulting document is correct.
* @type {Array<Uint8Array>}
*/
this.updates = []
// set up observe on local model
this.on(enc.updateEventName, /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
this.on('update', /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
if (origin !== testConnector) {
const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, update)
broadcastMessage(this, encoding.toUint8Array(encoder))
}
this.updates.push(update)
})
this.connect()
}
@@ -134,7 +95,12 @@ export class TestYInstance extends Y.Doc {
* @param {TestYInstance} remoteClient
*/
_receive (message, remoteClient) {
map.setIfUndefined(this.receiving, remoteClient, () => []).push(message)
let messages = this.receiving.get(remoteClient)
if (messages === undefined) {
messages = []
this.receiving.set(remoteClient, messages)
}
messages.push(message)
}
}
@@ -274,27 +240,19 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
users: []
}
const gen = tc.prng
// choose an encoding approach at random
if (prng.bool(gen)) {
useV2Encoding()
} else {
useV1Encoding()
}
const testConnector = new TestConnector(gen)
result.testConnector = testConnector
for (let i = 0; i < users; i++) {
const y = testConnector.createY(i)
y.clientID = i
result.users.push(y)
result['array' + i] = y.getArray('array')
result['map' + i] = y.getMap('map')
result['array' + i] = y.get('array', Y.Array)
result['map' + i] = y.get('map', Y.Map)
result['xml' + i] = y.get('xml', Y.XmlElement)
result['text' + i] = y.getText('text')
result['text' + i] = y.get('text', Y.Text)
}
testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
useV1Encoding()
return /** @type {any} */ (result)
}
@@ -309,22 +267,15 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
*/
export const compare = users => {
users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {} // eslint-disable-line
// For each document, merge all received document updates with Y.mergeUpdates and create a new document which will be added to the list of "users"
// This ensures that mergeUpdates works correctly
const mergedDocs = users.map(user => {
const ydoc = new Y.Doc()
enc.applyUpdate(ydoc, enc.mergeUpdates(user.updates))
return ydoc
})
users.push(.../** @type {any} */(mergedDocs))
while (users[0].tc.flushAllMessages()) {}
const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta())
for (const u of users) {
t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null)
t.assert(u.store.pendingDeleteReaders.length === 0)
t.assert(u.store.pendingStack.length === 0)
t.assert(u.store.pendingClientsStructRefs.size === 0)
}
// Test Array iterator
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))
@@ -347,31 +298,24 @@ export const compare = users => {
t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1])
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1], '', (constructor, a, b) => {
if (a instanceof Y.AbstractType) {
t.compare(a.toJSON(), b.toJSON())
} else if (a !== b) {
t.fail('Deltas dont match')
}
return true
})
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
t.compare(userTextValues[i], userTextValues[i + 1])
t.compare(getStateVector(users[i].store), getStateVector(users[i + 1].store))
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
compareStructStores(users[i].store, users[i + 1].store)
}
users.map(u => u.destroy())
}
/**
* @param {Y.Item?} a
* @param {Y.Item?} b
* @param {Item?} a
* @param {Item?} b
* @return {boolean}
*/
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
/**
* @param {import('../src/internals').StructStore} ss1
* @param {import('../src/internals').StructStore} ss2
* @param {StructStore} ss1
* @param {StructStore} ss2
*/
export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size)
@@ -391,9 +335,9 @@ export const compareStructStores = (ss1, ss2) => {
) {
t.fail('Structs dont match')
}
if (s1 instanceof Y.Item) {
if (s1 instanceof Item) {
if (
!(s2 instanceof Y.Item) ||
!(s2 instanceof Item) ||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
!compareItemIDs(s1.right, s2.right) ||
!Y.compareIDs(s1.origin, s2.origin) ||
@@ -413,13 +357,13 @@ export const compareStructStores = (ss1, ss2) => {
}
/**
* @param {import('../src/internals').DeleteSet} ds1
* @param {import('../src/internals').DeleteSet} ds2
* @param {DeleteSet} ds1
* @param {DeleteSet} ds2
*/
export const compareDS = (ds1, ds2) => {
t.assert(ds1.clients.size === ds2.clients.size)
ds1.clients.forEach((deleteItems1, client) => {
const deleteItems2 = /** @type {Array<import('../src/internals').DeleteItem>} */ (ds2.clients.get(client))
for (const [client, deleteItems1] of ds1.clients) {
const deleteItems2 = /** @type {Array<DeleteItem>} */ (ds2.clients.get(client))
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i]
@@ -428,7 +372,7 @@ export const compareDS = (ds1, ds2) => {
t.fail('DeleteSets dont match')
}
}
})
}
}
/**
@@ -450,21 +394,21 @@ export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result
for (let i = 0; i < iterations; i++) {
if (prng.int32(gen, 0, 100) <= 2) {
if (prng.int31(gen, 0, 100) <= 2) {
// 2% chance to disconnect/reconnect a random user
if (prng.bool(gen)) {
testConnector.disconnectRandom()
} else {
testConnector.reconnectRandom()
}
} else if (prng.int32(gen, 0, 100) <= 1) {
} else if (prng.int31(gen, 0, 100) <= 1) {
// 1% chance to flush all
testConnector.flushAllMessages()
} else if (prng.int32(gen, 0, 100) <= 50) {
} else if (prng.int31(gen, 0, 100) <= 50) {
// 50% chance to flush a random message
testConnector.flushRandomMessage()
}
const user = prng.int32(gen, 0, users.length - 1)
const user = prng.int31(gen, 0, users.length - 1)
const test = prng.oneOf(gen, mods)
test(users[user], gen, result.testObjects[user])
}

View File

@@ -1,14 +1,18 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import {
UndoManager
} from '../src/internals.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as t from 'lib0/testing.js'
/**
* @param {t.TestCase} tc
*/
export const testUndoText = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
const undoManager = new UndoManager(text0)
// items that are added & deleted in the same transaction won't be undo
text0.insert(0, 'test')
@@ -49,35 +53,13 @@ export const testUndoText = tc => {
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
}
/**
* Test case to fix #241
* @param {t.TestCase} tc
*/
export const testDoubleUndo = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, '1221')
const manager = new Y.UndoManager(text)
text.insert(2, '3')
text.insert(3, '3')
manager.undo()
manager.undo()
text.insert(2, '3')
t.compareStrings(text.toString(), '12321')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoMap = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 0)
const undoManager = new Y.UndoManager(map0)
const undoManager = new UndoManager(map0)
map0.set('a', 1)
undoManager.undo()
t.assert(map0.get('a') === 0)
@@ -116,7 +98,7 @@ export const testUndoMap = tc => {
*/
export const testUndoArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(array0)
const undoManager = new UndoManager(array0)
array0.insert(0, [1, 2, 3])
array1.insert(0, [4, 5, 6])
testConnector.syncAll()
@@ -167,7 +149,7 @@ export const testUndoArray = tc => {
*/
export const testUndoXml = tc => {
const { xml0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(xml0)
const undoManager = new UndoManager(xml0)
const child = new Y.XmlElement('p')
xml0.insert(0, [child])
const textchild = new Y.XmlText('content')
@@ -192,17 +174,15 @@ export const testUndoXml = tc => {
*/
export const testUndoEvents = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
const undoManager = new UndoManager(text0)
let counter = 0
let receivedMetadata = -1
undoManager.on('stack-item-added', /** @param {any} event */ event => {
t.assert(event.type != null)
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
event.stackItem.meta.set('test', counter++)
})
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
t.assert(event.type != null)
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
receivedMetadata = event.stackItem.meta.get('test')
})
text0.insert(0, 'abc')
@@ -218,7 +198,7 @@ export const testUndoEvents = tc => {
export const testTrackClass = tc => {
const { users, text0 } = init(tc, { users: 3 })
// only track origins that are numbers
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
const undoManager = new UndoManager(text0, { trackedOrigins: new Set([Number]) })
users[0].transact(() => {
text0.insert(0, 'abc')
}, 42)
@@ -236,8 +216,8 @@ export const testTypeScope = tc => {
const text0 = new Y.Text()
const text1 = new Y.Text()
array0.insert(0, [text0, text1])
const undoManager = new Y.UndoManager(text0)
const undoManagerBoth = new Y.UndoManager([text0, text1])
const undoManager = new UndoManager(text0)
const undoManagerBoth = new UndoManager([text0, text1])
text1.insert(0, 'abc')
t.assert(undoManager.undoStack.length === 0)
t.assert(undoManagerBoth.undoStack.length === 1)
@@ -248,35 +228,15 @@ export const testTypeScope = tc => {
t.assert(text1.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoInEmbed = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
const nestedText = new Y.Text('initial text')
undoManager.stopCapturing()
text0.insertEmbed(0, nestedText, { bold: true })
t.assert(nestedText.toString() === 'initial text')
undoManager.stopCapturing()
nestedText.delete(0, nestedText.length)
nestedText.insert(0, 'other text')
t.assert(nestedText.toString() === 'other text')
undoManager.undo()
t.assert(nestedText.toString() === 'initial text')
undoManager.undo()
t.assert(text0.length === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testUndoDeleteFilter = tc => {
/**
* @type {Y.Array<any>}
* @type {Array<Y.Map<any>>}
*/
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const undoManager = new UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const map0 = new Y.Map()
map0.set('hi', 1)
const map1 = new Y.Map()
@@ -286,305 +246,3 @@ export const testUndoDeleteFilter = tc => {
array0.get(0)
t.assert(Array.from(array0.get(0).keys()).length === 1)
}
/**
* This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6
* @param {t.TestCase} tc
*/
export const testUndoUntilChangePerformed = tc => {
const doc = new Y.Doc()
const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update))
doc2.on('update', update => Y.applyUpdate(doc, update))
const yArray = doc.getArray('array')
const yArray2 = doc2.getArray('array')
const yMap = new Y.Map()
yMap.set('hello', 'world')
yArray.push([yMap])
const yMap2 = new Y.Map()
yMap2.set('key', 'value')
yArray.push([yMap2])
const undoManager = new Y.UndoManager([yArray], { trackedOrigins: new Set([doc.clientID]) })
const undoManager2 = new Y.UndoManager([doc2.get('array')], { trackedOrigins: new Set([doc2.clientID]) })
Y.transact(doc, () => yMap2.set('key', 'value modified'), doc.clientID)
undoManager.stopCapturing()
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID)
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
undoManager2.undo()
undoManager.undo()
t.compareStrings(yMap2.get('key'), 'value')
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/317
* @param {t.TestCase} tc
*/
export const testUndoNestedUndoIssue = tc => {
const doc = new Y.Doc({ gc: false })
const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
/**
* @type {Y.Map<any>}
*/
const text = new Y.Map()
const blocks1 = new Y.Array()
const blocks1block = new Y.Map()
doc.transact(() => {
blocks1block.set('text', 'Type Something')
blocks1.push([blocks1block])
text.set('blocks', blocks1block)
design.set('text', text)
})
const blocks2 = new Y.Array()
const blocks2block = new Y.Map()
doc.transact(() => {
blocks2block.set('text', 'Something')
blocks2.push([blocks2block])
text.set('blocks', blocks2block)
})
const blocks3 = new Y.Array()
const blocks3block = new Y.Map()
doc.transact(() => {
blocks3block.set('text', 'Something Else')
blocks3.push([blocks3block])
text.set('blocks', blocks3block)
})
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/355
*
* @param {t.TestCase} tc
*/
export const testConsecutiveRedoBug = tc => {
const doc = new Y.Doc()
const yRoot = doc.getMap()
const undoMgr = new Y.UndoManager(yRoot)
let yPoint = new Y.Map()
yPoint.set('x', 0)
yPoint.set('y', 0)
yRoot.set('a', yPoint)
undoMgr.stopCapturing()
yPoint.set('x', 100)
yPoint.set('y', 100)
undoMgr.stopCapturing()
yPoint.set('x', 200)
yPoint.set('y', 200)
undoMgr.stopCapturing()
yPoint.set('x', 300)
yPoint.set('y', 300)
undoMgr.stopCapturing()
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
undoMgr.undo() // x=200, y=200
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
undoMgr.undo() // x=100, y=100
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
undoMgr.undo() // x=0, y=0
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
undoMgr.undo() // nil
t.compare(yRoot.get('a'), undefined)
undoMgr.redo() // x=0, y=0
yPoint = yRoot.get('a')
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
undoMgr.redo() // x=100, y=100
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
undoMgr.redo() // x=200, y=200
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
undoMgr.redo() // expected x=300, y=300, actually nil
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/304
*
* @param {t.TestCase} tc
*/
export const testUndoXmlBug = tc => {
const origin = 'origin'
const doc = new Y.Doc()
const fragment = doc.getXmlFragment('t')
const undoManager = new Y.UndoManager(fragment, {
captureTimeout: 0,
trackedOrigins: new Set([origin])
})
// create element
doc.transact(() => {
const e = new Y.XmlElement('test-node')
e.setAttribute('a', '100')
e.setAttribute('b', '0')
fragment.insert(fragment.length, [e])
}, origin)
// change one attribute
doc.transact(() => {
const e = fragment.get(0)
e.setAttribute('a', '200')
}, origin)
// change both attributes
doc.transact(() => {
const e = fragment.get(0)
e.setAttribute('a', '180')
e.setAttribute('b', '50')
}, origin)
undoManager.undo()
undoManager.undo()
undoManager.undo()
undoManager.redo()
undoManager.redo()
undoManager.redo()
t.compare(fragment.toString(), '<test-node a="180" b="50"></test-node>')
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/343
*
* @param {t.TestCase} tc
*/
export const testUndoBlockBug = tc => {
const doc = new Y.Doc({ gc: false })
const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
const text = new Y.Map()
const blocks1 = new Y.Array()
const blocks1block = new Y.Map()
doc.transact(() => {
blocks1block.set('text', '1')
blocks1.push([blocks1block])
text.set('blocks', blocks1block)
design.set('text', text)
})
const blocks2 = new Y.Array()
const blocks2block = new Y.Map()
doc.transact(() => {
blocks2block.set('text', '2')
blocks2.push([blocks2block])
text.set('blocks', blocks2block)
})
const blocks3 = new Y.Array()
const blocks3block = new Y.Map()
doc.transact(() => {
blocks3block.set('text', '3')
blocks3.push([blocks3block])
text.set('blocks', blocks3block)
})
const blocks4 = new Y.Array()
const blocks4block = new Y.Map()
doc.transact(() => {
blocks4block.set('text', '4')
blocks4.push([blocks4block])
text.set('blocks', blocks4block)
})
// {"text":{"blocks":{"text":"4"}}}
undoManager.undo() // {"text":{"blocks":{"3"}}}
undoManager.undo() // {"text":{"blocks":{"text":"2"}}}
undoManager.undo() // {"text":{"blocks":{"text":"1"}}}
undoManager.undo() // {}
undoManager.redo() // {"text":{"blocks":{"text":"1"}}}
undoManager.redo() // {"text":{"blocks":{"text":"2"}}}
undoManager.redo() // {"text":{"blocks":{"text":"3"}}}
undoManager.redo() // {"text":{}}
t.compare(design.toJSON(), { text: { blocks: { text: '4' } } })
}
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc
*/
export const testUndoDeleteTextFormat = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.getText()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const undoManager = new Y.UndoManager(text)
text.format(13, 7, { bold: true })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(16, 4, { bold: null })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
undoManager.undo()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const expect = [
{ insert: 'Attack ships ' },
{
insert: 'on fire',
attributes: { bold: true }
},
{ insert: ' off the shoulder of Orion.' }
]
t.compare(text.toDelta(), expect)
t.compare(text2.toDelta(), expect)
}
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc
*/
export const testBehaviorOfIgnoreremotemapchangesProperty = tc => {
const doc = new Y.Doc()
const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
const map1 = doc.getMap()
const map2 = doc2.getMap()
const um1 = new Y.UndoManager(map1, { ignoreRemoteMapChanges: true })
map1.set('x', 1)
map2.set('x', 2)
map1.set('x', 3)
map2.set('x', 4)
um1.undo()
t.assert(map1.get('x') === 2)
t.assert(map2.get('x') === 2)
}

View File

@@ -1,306 +0,0 @@
import * as t from 'lib0/testing'
import { init, compare } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
/**
* @typedef {Object} Enc
* @property {function(Array<Uint8Array>):Uint8Array} Enc.mergeUpdates
* @property {function(Y.Doc):Uint8Array} Enc.encodeStateAsUpdate
* @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate
* @property {function(Uint8Array):void} Enc.logUpdate
* @property {function(Uint8Array):{from:Map<number,number>,to:Map<number,number>}} Enc.parseUpdateMeta
* @property {function(Y.Doc):Uint8Array} Enc.encodeStateVector
* @property {function(Uint8Array):Uint8Array} Enc.encodeStateVectorFromUpdate
* @property {string} Enc.updateEventName
* @property {string} Enc.description
* @property {function(Uint8Array, Uint8Array):Uint8Array} Enc.diffUpdate
*/
/**
* @type {Enc}
*/
const encV1 = {
mergeUpdates: Y.mergeUpdates,
encodeStateAsUpdate: Y.encodeStateAsUpdate,
applyUpdate: Y.applyUpdate,
logUpdate: Y.logUpdate,
parseUpdateMeta: Y.parseUpdateMeta,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdate,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'update',
description: 'V1',
diffUpdate: Y.diffUpdate
}
/**
* @type {Enc}
*/
const encV2 = {
mergeUpdates: Y.mergeUpdatesV2,
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
parseUpdateMeta: Y.parseUpdateMetaV2,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'updateV2',
description: 'V2',
diffUpdate: Y.diffUpdateV2
}
/**
* @type {Enc}
*/
const encDoc = {
mergeUpdates: (updates) => {
const ydoc = new Y.Doc({ gc: false })
updates.forEach(update => {
Y.applyUpdateV2(ydoc, update)
})
return Y.encodeStateAsUpdateV2(ydoc)
},
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
parseUpdateMeta: Y.parseUpdateMetaV2,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'updateV2',
description: 'Merge via Y.Doc',
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
*/
diffUpdate: (update, sv) => {
const ydoc = new Y.Doc({ gc: false })
Y.applyUpdateV2(ydoc, update)
return Y.encodeStateAsUpdateV2(ydoc, sv)
}
}
const encoders = [encV1, encV2, encDoc]
/**
* @param {Array<Y.Doc>} users
* @param {Enc} enc
*/
const fromUpdates = (users, enc) => {
const updates = users.map(user =>
enc.encodeStateAsUpdate(user)
)
const ydoc = new Y.Doc()
enc.applyUpdate(ydoc, enc.mergeUpdates(updates))
return ydoc
}
/**
* @param {t.TestCase} tc
*/
export const testMergeUpdates = tc => {
const { users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, [1])
array1.insert(0, [2])
compare(users)
encoders.forEach(enc => {
const merged = fromUpdates(users, enc)
t.compareArrays(array0.toArray(), merged.getArray('array').toArray())
})
}
/**
* @param {t.TestCase} tc
*/
export const testKeyEncoding = tc => {
const { users, text0, text1 } = init(tc, { users: 2 })
text0.insert(0, 'a', { italic: true })
text0.insert(0, 'b')
text0.insert(0, 'c', { italic: true })
const update = Y.encodeStateAsUpdateV2(users[0])
Y.applyUpdateV2(users[1], update)
t.compare(text1.toDelta(), [{ insert: 'c', attributes: { italic: true } }, { insert: 'b' }, { insert: 'a', attributes: { italic: true } }])
compare(users)
}
/**
* @param {Y.Doc} ydoc
* @param {Array<Uint8Array>} updates - expecting at least 4 updates
* @param {Enc} enc
* @param {boolean} hasDeletes
*/
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
const cases = []
// Case 1: Simple case, simply merge everything
cases.push(enc.mergeUpdates(updates))
// Case 2: Overlapping updates
cases.push(enc.mergeUpdates([
enc.mergeUpdates(updates.slice(2)),
enc.mergeUpdates(updates.slice(0, 2))
]))
// Case 3: Overlapping updates
cases.push(enc.mergeUpdates([
enc.mergeUpdates(updates.slice(2)),
enc.mergeUpdates(updates.slice(1, 3)),
updates[0]
]))
// Case 4: Separated updates (containing skips)
cases.push(enc.mergeUpdates([
enc.mergeUpdates([updates[0], updates[2]]),
enc.mergeUpdates([updates[1], updates[3]]),
enc.mergeUpdates(updates.slice(4))
]))
// Case 5: overlapping with many duplicates
cases.push(enc.mergeUpdates(cases))
// const targetState = enc.encodeStateAsUpdate(ydoc)
// t.info('Target State: ')
// enc.logUpdate(targetState)
cases.forEach((mergedUpdates, i) => {
// t.info('State Case $' + i + ':')
// enc.logUpdate(updates)
const merged = new Y.Doc({ gc: false })
enc.applyUpdate(merged, mergedUpdates)
t.compareArrays(merged.getArray().toArray(), ydoc.getArray().toArray())
t.compare(enc.encodeStateVector(merged), enc.encodeStateVectorFromUpdate(mergedUpdates))
if (enc.updateEventName !== 'update') { // @todo should this also work on legacy updates?
for (let j = 1; j < updates.length; j++) {
const partMerged = enc.mergeUpdates(updates.slice(j))
const partMeta = enc.parseUpdateMeta(partMerged)
const targetSV = Y.encodeStateVectorFromUpdateV2(Y.mergeUpdatesV2(updates.slice(0, j)))
const diffed = enc.diffUpdate(mergedUpdates, targetSV)
const diffedMeta = enc.parseUpdateMeta(diffed)
t.compare(partMeta, diffedMeta)
{
// We can'd do the following
// - t.compare(diffed, mergedDeletes)
// because diffed contains the set of all deletes.
// So we add all deletes from `diffed` to `partDeletes` and compare then
const decoder = decoding.createDecoder(diffed)
const updateDecoder = new UpdateDecoderV2(decoder)
readClientsStructRefs(updateDecoder, new Y.Doc())
const ds = readDeleteSet(updateDecoder)
const updateEncoder = new UpdateEncoderV2()
encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs
writeDeleteSet(updateEncoder, ds)
const deletesUpdate = updateEncoder.toUint8Array()
const mergedDeletes = Y.mergeUpdatesV2([deletesUpdate, partMerged])
if (!hasDeletes || enc !== encDoc) {
// deletes will almost definitely lead to different encoders because of the mergeStruct feature that is present in encDoc
t.compare(diffed, mergedDeletes)
}
}
}
}
const meta = enc.parseUpdateMeta(mergedUpdates)
meta.from.forEach((clock, client) => t.assert(clock === 0))
meta.to.forEach((clock, client) => {
const structs = /** @type {Array<Y.Item>} */ (merged.store.clients.get(client))
const lastStruct = structs[structs.length - 1]
t.assert(lastStruct.id.clock + lastStruct.length === clock)
})
})
}
/**
* @param {t.TestCase} tc
*/
export const testMergeUpdates1 = tc => {
encoders.forEach((enc, i) => {
t.info(`Using encoder: ${enc.description}`)
const ydoc = new Y.Doc({ gc: false })
const updates = /** @type {Array<Uint8Array>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.getArray()
array.insert(0, [1])
array.insert(0, [2])
array.insert(0, [3])
array.insert(0, [4])
checkUpdateCases(ydoc, updates, enc, false)
})
}
/**
* @param {t.TestCase} tc
*/
export const testMergeUpdates2 = tc => {
encoders.forEach((enc, i) => {
t.info(`Using encoder: ${enc.description}`)
const ydoc = new Y.Doc({ gc: false })
const updates = /** @type {Array<Uint8Array>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.getArray()
array.insert(0, [1, 2])
array.delete(1, 1)
array.insert(0, [3, 4])
array.delete(1, 2)
checkUpdateCases(ydoc, updates, enc, true)
})
}
/**
* @param {t.TestCase} tc
*/
export const testMergePendingUpdates = tc => {
const yDoc = new Y.Doc()
/**
* @type {Array<Uint8Array>}
*/
const serverUpdates = []
yDoc.on('update', (update, origin, c) => {
serverUpdates.splice(serverUpdates.length, 0, update)
})
const yText = yDoc.getText('textBlock')
yText.applyDelta([{ insert: 'r' }])
yText.applyDelta([{ insert: 'o' }])
yText.applyDelta([{ insert: 'n' }])
yText.applyDelta([{ insert: 'e' }])
yText.applyDelta([{ insert: 'n' }])
const yDoc1 = new Y.Doc()
Y.applyUpdate(yDoc1, serverUpdates[0])
const update1 = Y.encodeStateAsUpdate(yDoc1)
const yDoc2 = new Y.Doc()
Y.applyUpdate(yDoc2, update1)
Y.applyUpdate(yDoc2, serverUpdates[1])
const update2 = Y.encodeStateAsUpdate(yDoc2)
const yDoc3 = new Y.Doc()
Y.applyUpdate(yDoc3, update2)
Y.applyUpdate(yDoc3, serverUpdates[3])
const update3 = Y.encodeStateAsUpdate(yDoc3)
const yDoc4 = new Y.Doc()
Y.applyUpdate(yDoc4, update3)
Y.applyUpdate(yDoc4, serverUpdates[2])
const update4 = Y.encodeStateAsUpdate(yDoc4)
const yDoc5 = new Y.Doc()
Y.applyUpdate(yDoc5, update4)
Y.applyUpdate(yDoc5, serverUpdates[4])
// @ts-ignore
const update5 = Y.encodeStateAsUpdate(yDoc5) // eslint-disable-line
const yText5 = yDoc5.getText('textBlock')
t.compareStrings(yText5.toString(), 'nenor')
}

View File

@@ -1,108 +1,9 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as math from 'lib0/math'
/**
* @param {t.TestCase} tc
*/
export const testBasicUpdate = tc => {
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
doc1.getArray('array').insert(0, ['hi'])
const update = Y.encodeStateAsUpdate(doc1)
Y.applyUpdate(doc2, update)
t.compare(doc2.getArray('array').toArray(), ['hi'])
}
/**
* @param {t.TestCase} tc
*/
export const testSlice = tc => {
const doc1 = new Y.Doc()
const arr = doc1.getArray('array')
arr.insert(0, [1, 2, 3])
t.compareArrays(arr.slice(0), [1, 2, 3])
t.compareArrays(arr.slice(1), [2, 3])
t.compareArrays(arr.slice(0, -1), [1, 2])
arr.insert(0, [0])
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
t.compareArrays(arr.slice(0, 2), [0, 1])
}
/**
* Debugging yjs#297 - a critical bug connected to the search-marker approach
*
* @param {t.TestCase} tc
*/
export const testLengthIssue = tc => {
const doc1 = new Y.Doc()
const arr = doc1.getArray('array')
arr.push([0, 1, 2, 3])
arr.delete(0)
arr.insert(0, [0])
t.assert(arr.length === arr.toArray().length)
doc1.transact(() => {
arr.delete(1)
t.assert(arr.length === arr.toArray().length)
arr.insert(1, [1])
t.assert(arr.length === arr.toArray().length)
arr.delete(2)
t.assert(arr.length === arr.toArray().length)
arr.insert(2, [2])
t.assert(arr.length === arr.toArray().length)
})
t.assert(arr.length === arr.toArray().length)
arr.delete(1)
t.assert(arr.length === arr.toArray().length)
arr.insert(1, [1])
t.assert(arr.length === arr.toArray().length)
}
/**
* Debugging yjs#314
*
* @param {t.TestCase} tc
*/
export const testLengthIssue2 = tc => {
const doc = new Y.Doc()
const next = doc.getArray()
doc.transact(() => {
next.insert(0, ['group2'])
})
doc.transact(() => {
next.insert(1, ['rectangle3'])
})
doc.transact(() => {
next.delete(0)
next.insert(0, ['rectangle3'])
})
next.delete(1)
doc.transact(() => {
next.insert(1, ['ellipse4'])
})
doc.transact(() => {
next.insert(2, ['ellipse3'])
})
doc.transact(() => {
next.insert(3, ['ellipse2'])
})
doc.transact(() => {
doc.transact(() => {
t.fails(() => {
next.insert(5, ['rectangle2'])
})
next.insert(4, ['rectangle2'])
})
doc.transact(() => {
// this should not throw an error message
next.delete(4)
})
})
console.log(next.toArray())
}
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
/**
* @param {t.TestCase} tc
@@ -136,7 +37,7 @@ export const testInsertThreeElementsTryRegetProperty = tc => {
* @param {t.TestCase} tc
*/
export const testConcurrentInsertWithThreeConflicts = tc => {
const { users, array0, array1, array2 } = init(tc, { users: 3 })
var { users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, [0])
array1.insert(0, [1])
array2.insert(0, [2])
@@ -179,7 +80,7 @@ export const testInsertionsInLateSync = tc => {
* @param {t.TestCase} tc
*/
export const testDisconnectReallyPreventsSendingMessages = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 3 })
var { testConnector, users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
@@ -291,34 +192,6 @@ export const testInsertAndDeleteEventsForTypes = tc => {
compare(users)
}
/**
* This issue has been reported in https://discuss.yjs.dev/t/order-in-which-events-yielded-by-observedeep-should-be-applied/261/2
*
* Deep observers generate multiple events. When an array added at item at, say, position 0,
* and item 1 changed then the array-add event should fire first so that the change event
* path is correct. A array binding might lead to an inconsistent state otherwise.
*
* @param {t.TestCase} tc
*/
export const testObserveDeepEventOrder = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<any>}
*/
let events = []
array0.observeDeep(e => {
events = e
})
array0.insert(0, [new Y.Map()])
users[0].transact(() => {
array0.get(0).set('a', 'a')
array0.insert(0, [0])
})
for (let i = 1; i < events.length; i++) {
t.assert(events[i - 1].path.length <= events[i].path.length, 'path size increases, fire top-level events first')
}
}
/**
* @param {t.TestCase} tc
*/
@@ -460,57 +333,46 @@ const getUniqueNumber = () => _uniqueNumber++
const arrayTransactions = [
function insert (user, gen) {
const yarray = user.getArray('array')
const uniqueNumber = getUniqueNumber()
const content = []
const len = prng.int32(gen, 1, 4)
for (let i = 0; i < len; i++) {
var uniqueNumber = getUniqueNumber()
var content = []
var len = prng.int31(gen, 1, 4)
for (var i = 0; i < len; i++) {
content.push(uniqueNumber)
}
const pos = prng.int32(gen, 0, yarray.length)
const oldContent = yarray.toArray()
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, content)
oldContent.splice(pos, 0, ...content)
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
},
function insertTypeArray (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Array()])
const array2 = yarray.get(pos)
var array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Map()])
const map = yarray.get(pos)
var map = yarray.get(pos)
map.set('someprop', 42)
map.set('someprop', 43)
map.set('someprop', 44)
},
function insertTypeNull (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [null])
},
function _delete (user, gen) {
const yarray = user.getArray('array')
const length = yarray.length
var length = yarray.length
if (length > 0) {
let somePos = prng.int32(gen, 0, length - 1)
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
var somePos = prng.int31(gen, 0, length - 1)
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
const type = yarray.get(somePos)
if (type instanceof Y.Array && type.length > 0) {
somePos = prng.int32(gen, 0, type.length - 1)
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
var type = yarray.get(somePos)
if (type.length > 0) {
somePos = prng.int31(gen, 0, type.length - 1)
delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength)
}
} else {
const oldContent = yarray.toArray()
yarray.delete(somePos, delLength)
oldContent.splice(somePos, delLength)
t.compareArrays(yarray.toArray(), oldContent)
}
}
}
@@ -519,8 +381,8 @@ const arrayTransactions = [
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests6 = tc => {
applyRandomTests(tc, arrayTransactions, 6)
export const testRepeatGeneratingYarrayTests4 = tc => {
applyRandomTests(tc, arrayTransactions, 4)
}
/**

View File

@@ -5,8 +5,8 @@ import {
} from '../src/internals.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
/**
* @param {t.TestCase} tc
@@ -42,7 +42,6 @@ export const testBasicMapTests = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
users[2].disconnect()
map0.set('null', null)
map0.set('number', 1)
map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } })
@@ -55,29 +54,26 @@ export const testBasicMapTests = tc => {
array.insert(0, [0])
array.insert(0, [-1])
t.assert(map0.get('null') === null, 'client 0 computed the change (null)')
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
t.assert(map0.size === 7, 'client 0 map has correct size')
t.assert(map0.size === 6, 'client 0 map has correct size')
users[2].connect()
testConnector.flushAllMessages()
t.assert(map1.get('null') === null, 'client 1 received the update (null)')
t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
t.assert(map1.size === 7, 'client 1 map has correct size')
t.assert(map1.size === 6, 'client 1 map has correct size')
// compare disconnected user
t.assert(map2.get('null') === null, 'client 2 received the update (null) - was disconnected')
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')
@@ -142,7 +138,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
var u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
}
compare(users)
@@ -157,7 +153,7 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c1')
}
compare(users)
@@ -187,55 +183,12 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
map1.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapProperties = tc => {
const { testConnector, users, map0 } = init(tc, { users: 1 })
map0.set('stuff', 'c0')
map0.set('otherstuff', 'c1')
map0.clear()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
t.assert(u.get('otherstuff') === undefined)
t.assert(u.size === 0, `map size after clear is ${u.size}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapPropertiesWithConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
map0.set('otherstuff', 'c0')
map1.set('otherstuff', 'c1')
map2.set('otherstuff', 'c2')
map3.set('otherstuff', 'c3')
map3.clear()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
t.assert(u.get('otherstuff') === undefined)
t.assert(u.size === 0, `map size after clear is ${u.size}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
@@ -247,7 +200,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c3')
}
compare(users)
@@ -270,7 +223,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
map3.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
@@ -343,7 +296,7 @@ export const testObserversUsingObservedeep = tc => {
* @param {Object<string,any>} should
*/
const compareEvent = (is, should) => {
for (const key in should) {
for (var key in should) {
t.compare(should[key], is[key])
}
}
@@ -382,30 +335,6 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testThrowsDeleteEventsOnClear = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e // just put it on event, should be thrown synchronously anyway
})
// set values
map0.set('stuff', 4)
map0.set('otherstuff', new Y.Array())
// clear
map0.clear()
compareEvent(event, {
keysChanged: new Set(['stuff', 'otherstuff']),
target: map0
})
compare(users)
}
/**
* @param {t.TestCase} tc
*/
@@ -545,12 +474,12 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
const mapTransactions = [
function set (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
const value = prng.utf16String(gen)
var value = prng.utf16String(gen)
user.getMap('map').set(key, value)
},
function setType (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
const type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
user.getMap('map').set(key, type)
if (type instanceof Y.Array) {
type.insert(0, [1, 2, 3, 4])

View File

@@ -1,36 +1,10 @@
import * as Y from './testHelper.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as math from 'lib0/math'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
const { init, compare } = Y
/**
* In this test we are mainly interested in the cleanup behavior and whether the resulting delta makes sense.
* It is fine if the resulting delta is not minimal. But applying the delta to a rich-text editor should result in a
* synced document.
*
* @param {t.TestCase} tc
*/
export const testDeltaAfterConcurrentFormatting = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
text0.insert(0, 'abcde')
testConnector.flushAllMessages()
text0.format(0, 3, { bold: true })
text1.format(2, 2, { bold: true })
/**
* @type {any}
*/
const deltas = []
text1.observe(event => {
if (event.delta.length > 0) {
deltas.push(event.delta)
}
})
testConnector.flushAllMessages()
t.compare(deltas, [[{ retain: 3, attributes: { bold: true } }, { retain: 2, attributes: { bold: null } }]])
}
/**
* @param {t.TestCase} tc
*/
@@ -104,71 +78,6 @@ export const testBasicFormat = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testMultilineFormat = tc => {
const ydoc = new Y.Doc()
const testText = ydoc.getText('test')
testText.insert(0, 'Test\nMulti-line\nFormatting')
testText.applyDelta([
{ retain: 4, attributes: { bold: true } },
{ retain: 1 }, // newline character
{ retain: 10, attributes: { bold: true } },
{ retain: 1 }, // newline character
{ retain: 10, attributes: { bold: true } }
])
t.compare(testText.toDelta(), [
{ insert: 'Test', attributes: { bold: true } },
{ insert: '\n' },
{ insert: 'Multi-line', attributes: { bold: true } },
{ insert: '\n' },
{ insert: 'Formatting', attributes: { bold: true } }
])
}
/**
* @param {t.TestCase} tc
*/
export const testNotMergeEmptyLinesFormat = tc => {
const ydoc = new Y.Doc()
const testText = ydoc.getText('test')
testText.applyDelta([
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } },
{ insert: '\nText' },
{ insert: '\n', attributes: { title: true } }
])
t.compare(testText.toDelta(), [
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } },
{ insert: '\nText' },
{ insert: '\n', attributes: { title: true } }
])
}
/**
* @param {t.TestCase} tc
*/
export const testPreserveAttributesThroughDelete = tc => {
const ydoc = new Y.Doc()
const testText = ydoc.getText('test')
testText.applyDelta([
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } },
{ insert: '\n' }
])
testText.applyDelta([
{ retain: 4 },
{ delete: 1 },
{ retain: 1, attributes: { title: true } }
])
t.compare(testText.toDelta(), [
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } }
])
}
/**
* @param {t.TestCase} tc
*/
@@ -182,29 +91,6 @@ export const testGetDeltaWithEmbeds = tc => {
}])
}
/**
* @param {t.TestCase} tc
*/
export const testTypesAsEmbed = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
text0.applyDelta([{
insert: new Y.Map([['key', 'val']])
}])
t.compare(text0.toDelta()[0].insert.toJSON(), { key: 'val' })
let firedEvent = false
text1.observe(event => {
const d = event.delta
t.assert(d.length === 1)
t.compare(d.map(x => /** @type {Y.AbstractType<any>} */ (x.insert).toJSON()), [{ key: 'val' }])
firedEvent = true
})
testConnector.flushAllMessages()
const delta = text1.toDelta()
t.assert(delta.length === 1)
t.compare(delta[0].insert.toJSON(), { key: 'val' })
t.assert(firedEvent, 'fired the event observer containing a Type-Embed')
}
/**
* @param {t.TestCase} tc
*/
@@ -319,125 +205,8 @@ export const testFormattingRemovedInMidText = tc => {
t.assert(Y.getTypeChildren(text0).length === 3)
}
/**
* Reported in https://github.com/yjs/yjs/issues/344
*
* @param {t.TestCase} tc
*/
export const testFormattingDeltaUnnecessaryAttributeChange = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
text0.insert(0, '\n', {
PARAGRAPH_STYLES: 'normal',
LIST_STYLES: 'bullet'
})
text0.insert(1, 'abc', {
PARAGRAPH_STYLES: 'normal'
})
testConnector.flushAllMessages()
/**
* @type {Array<any>}
*/
const deltas = []
text0.observe(event => {
deltas.push(event.delta)
})
text1.observe(event => {
deltas.push(event.delta)
})
text1.format(0, 1, { LIST_STYLES: 'number' })
testConnector.flushAllMessages()
const filteredDeltas = deltas.filter(d => d.length > 0)
t.assert(filteredDeltas.length === 2)
t.compare(filteredDeltas[0], [
{ retain: 1, attributes: { LIST_STYLES: 'number' } }
])
t.compare(filteredDeltas[0], filteredDeltas[1])
}
/**
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteAtRandomPositions = tc => {
const N = 100000
const { text0 } = init(tc, { users: 1 })
const gen = tc.prng
// create initial content
// let expectedResult = init
text0.insert(0, prng.word(gen, N / 2, N / 2))
// apply changes
for (let i = 0; i < N; i++) {
const pos = prng.uint32(gen, 0, text0.length)
if (prng.bool(gen)) {
const len = prng.uint32(gen, 1, 5)
const word = prng.word(gen, 0, len)
text0.insert(pos, word)
// expectedResult = expectedResult.slice(0, pos) + word + expectedResult.slice(pos)
} else {
const len = prng.uint32(gen, 0, math.min(3, text0.length - pos))
text0.delete(pos, len)
// expectedResult = expectedResult.slice(0, pos) + expectedResult.slice(pos + len)
}
}
// t.compareStrings(text0.toString(), expectedResult)
t.describe('final length', '' + text0.length)
}
/**
* @param {t.TestCase} tc
*/
export const testAppendChars = tc => {
const N = 10000
const { text0 } = init(tc, { users: 1 })
// apply changes
for (let i = 0; i < N; i++) {
text0.insert(text0.length, 'a')
}
t.assert(text0.length === N)
}
const largeDocumentSize = 100000
const id = Y.createID(0, 0)
const c = new Y.ContentString('a')
/**
* @param {t.TestCase} tc
*/
export const testBestCase = tc => {
const N = largeDocumentSize
const items = new Array(N)
t.measureTime('time to create two million items in the best case', () => {
const parent = /** @type {any} */ ({})
let prevItem = null
for (let i = 0; i < N; i++) {
/**
* @type {Y.Item}
*/
const n = new Y.Item(Y.createID(0, 0), null, null, null, null, null, null, c)
// items.push(n)
items[i] = n
n.right = prevItem
n.rightOrigin = prevItem ? id : null
n.content = c
n.parent = parent
prevItem = n
}
})
const newArray = new Array(N)
t.measureTime('time to copy two million items to new Array', () => {
for (let i = 0; i < N; i++) {
newArray[i] = items[i]
}
})
}
const tryGc = () => {
// @ts-ignore
if (typeof global !== 'undefined' && global.gc) {
// @ts-ignore
global.gc()
}
}
@@ -446,7 +215,7 @@ const tryGc = () => {
* @param {t.TestCase} tc
*/
export const testLargeFragmentedDocument = tc => {
const itemsToInsert = largeDocumentSize
const itemsToInsert = 2000000
let update = /** @type {any} */ (null)
;(() => {
const doc1 = new Y.Doc()
@@ -461,281 +230,22 @@ export const testLargeFragmentedDocument = tc => {
})
tryGc()
t.measureTime('time to encode document', () => {
update = Y.encodeStateAsUpdateV2(doc1)
update = Y.encodeStateAsUpdate(doc1)
})
t.describe('Document size:', update.byteLength)
})()
;(() => {
const doc2 = new Y.Doc()
tryGc()
t.measureTime(`time to apply ${itemsToInsert} updates`, () => {
Y.applyUpdateV2(doc2, update)
Y.applyUpdate(doc2, update)
})
})()
}
/**
* @param {t.TestCase} tc
*/
export const testIncrementalUpdatesPerformanceOnLargeFragmentedDocument = tc => {
const itemsToInsert = largeDocumentSize
const updates = /** @type {Array<Uint8Array>} */ ([])
;(() => {
const doc1 = new Y.Doc()
doc1.on('update', update => {
updates.push(update)
})
const text0 = doc1.getText('txt')
tryGc()
t.measureTime(`time to insert ${itemsToInsert} items`, () => {
doc1.transact(() => {
for (let i = 0; i < itemsToInsert; i++) {
text0.insert(0, '0')
}
})
})
tryGc()
})()
;(() => {
t.measureTime(`time to merge ${itemsToInsert} updates (differential updates)`, () => {
Y.mergeUpdates(updates)
})
tryGc()
t.measureTime(`time to merge ${itemsToInsert} updates (ydoc updates)`, () => {
const ydoc = new Y.Doc()
updates.forEach(update => {
Y.applyUpdate(ydoc, update)
})
})
})()
}
/**
* Splitting surrogates can lead to invalid encoded documents.
*
* https://github.com/yjs/yjs/issues/248
*
* @param {t.TestCase} tc
*/
export const testSplitSurrogateCharacter = tc => {
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾') // insert surrogate character
// split surrogate, which should not lead to an encoding error
text0.insert(1, 'hi!')
compare(users)
}
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾👾') // insert surrogate character
// partially delete surrogate
text0.delete(1, 2)
compare(users)
}
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾👾') // insert surrogate character
// formatting will also split surrogates
text0.format(1, 2, { bold: true })
compare(users)
}
}
/**
* Search marker bug https://github.com/yjs/yjs/issues/307
*
* @param {t.TestCase} tc
*/
export const testSearchMarkerBug1 = tc => {
const { users, text0, text1, testConnector } = init(tc, { users: 2 })
users[0].on('update', update => {
users[0].transact(() => {
Y.applyUpdate(users[0], update)
})
})
users[0].on('update', update => {
users[1].transact(() => {
Y.applyUpdate(users[1], update)
})
})
text0.insert(0, 'a_a')
testConnector.flushAllMessages()
text0.insert(2, 's')
testConnector.flushAllMessages()
text1.insert(3, 'd')
testConnector.flushAllMessages()
text0.delete(0, 5)
testConnector.flushAllMessages()
text0.insert(0, 'a_a')
testConnector.flushAllMessages()
text0.insert(2, 's')
testConnector.flushAllMessages()
text1.insert(3, 'd')
testConnector.flushAllMessages()
t.compareStrings(text0.toString(), text1.toString())
t.compareStrings(text0.toString(), 'a_sda')
compare(users)
}
/**
* Reported in https://github.com/yjs/yjs/pull/32
*
* @param {t.TestCase} tc
*/
export const testFormattingBug = async tc => {
const ydoc1 = new Y.Doc()
const ydoc2 = new Y.Doc()
const text1 = ydoc1.getText()
text1.insert(0, '\n\n\n')
text1.format(0, 3, { url: 'http://example.com' })
ydoc1.getText().format(1, 1, { url: 'http://docs.yjs.dev' })
ydoc2.getText().format(1, 1, { url: 'http://docs.yjs.dev' })
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1))
const text2 = ydoc2.getText()
const expectedResult = [
{ insert: '\n', attributes: { url: 'http://example.com' } },
{ insert: '\n', attributes: { url: 'http://docs.yjs.dev' } },
{ insert: '\n', attributes: { url: 'http://example.com' } }
]
t.compare(text1.toDelta(), expectedResult)
t.compare(text1.toDelta(), text2.toDelta())
console.log(text1.toDelta())
}
/**
* Delete formatting should not leave redundant formatting items.
*
* @param {t.TestCase} tc
*/
export const testDeleteFormatting = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.getText()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(13, 7, { bold: true })
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(16, 4, { bold: null })
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const expected = [
{ insert: 'Attack ships ' },
{ insert: 'on ', attributes: { bold: true } },
{ insert: 'fire off the shoulder of Orion.' }
]
t.compare(text.toDelta(), expected)
t.compare(text2.toDelta(), expected)
}
// RANDOM TESTS
let charCounter = 0
/**
* Random tests for pure text operations without formatting.
*
* @type Array<function(any,prng.PRNG):void>
*/
const textChanges = [
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert text
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.length)
const text = charCounter++ + prng.word(gen)
const prevText = ytext.toString()
ytext.insert(insertPos, text)
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + text + prevText.slice(insertPos))
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // delete text
const ytext = y.getText('text')
const contentLen = ytext.toString().length
const insertPos = prng.int32(gen, 0, contentLen)
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
const prevText = ytext.toString()
ytext.delete(insertPos, overwrite)
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + prevText.slice(insertPos + overwrite))
}
]
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges5 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 5))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges30 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 30))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges40 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 40))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges50 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 50))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges70 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 70))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges90 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 90))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateTextChanges300 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 300))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
const marks = [
{ bold: true },
{ italic: true },
@@ -748,8 +258,6 @@ const marksChoices = [
]
/**
* Random tests for all features of y-text (formatting, embeds, ..).
*
* @type Array<function(any,prng.PRNG):void>
*/
const qChanges = [
@@ -759,7 +267,7 @@ const qChanges = [
*/
(y, gen) => { // insert text
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.length)
const insertPos = prng.int32(gen, 0, ytext.toString().length)
const attrs = prng.oneOf(gen, marksChoices)
const text = charCounter++ + prng.word(gen)
ytext.insert(insertPos, text, attrs)
@@ -770,12 +278,8 @@ const qChanges = [
*/
(y, gen) => { // insert embed
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.length)
if (prng.bool(gen)) {
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
} else {
ytext.insertEmbed(insertPos, new Y.Map([[prng.word(gen), prng.word(gen)]]))
}
const insertPos = prng.int32(gen, 0, ytext.toString().length)
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
},
/**
* @param {Y.Doc} y
@@ -822,12 +326,8 @@ const qChanges = [
*/
const checkResult = result => {
for (let i = 1; i < result.testObjects.length; i++) {
/**
* @param {any} d
*/
const typeToObject = d => d.insert instanceof Y.AbstractType ? d.insert.toJSON() : d
const p1 = result.users[i].getText('text').toDelta().map(typeToObject)
const p2 = result.users[i].getText('text').toDelta().map(typeToObject)
const p1 = result.users[i].getText('text').toDelta()
const p2 = result.users[i].getText('text').toDelta()
t.compare(p1, p2)
}
// Uncomment this to find formatting-cleanup issues

View File

@@ -1,7 +1,7 @@
import { init, compare } from './testHelper.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as t from 'lib0/testing.js'
/**
* @param {t.TestCase} tc
@@ -15,23 +15,6 @@ export const testSetProperty = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testHasProperty = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
xml0.setAttribute('height', '10')
t.assert(xml0.hasAttribute('height'), 'Simple set+has works')
testConnector.flushAllMessages()
t.assert(xml1.hasAttribute('height'), 'Simple set+has works (remote)')
xml0.removeAttribute('height')
t.assert(!xml0.hasAttribute('height'), 'Simple set+remove+has works')
testConnector.flushAllMessages()
t.assert(!xml1.hasAttribute('height'), 'Simple set+remove+has works (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
@@ -90,96 +73,3 @@ export const testTreewalker = tc => {
t.assert(xml0.querySelector('p') === paragraph1, 'querySelector found paragraph1')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYtextAttributes = tc => {
const ydoc = new Y.Doc()
const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
ytext.observe(event => {
t.compare(event.changes.keys.get('test'), { action: 'add', oldValue: undefined })
})
ytext.setAttribute('test', 42)
t.compare(ytext.getAttribute('test'), 42)
t.compare(ytext.getAttributes(), { test: 42 })
}
/**
* @param {t.TestCase} tc
*/
export const testSiblings = tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
const second = new Y.XmlElement('p')
yxml.insert(0, [first, second])
t.assert(first.nextSibling === second)
t.assert(second.prevSibling === first)
t.assert(first.parent === yxml)
t.assert(yxml.parent === null)
t.assert(yxml.firstChild === first)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertafter = tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
const second = new Y.XmlElement('p')
const third = new Y.XmlElement('p')
const deepsecond1 = new Y.XmlElement('span')
const deepsecond2 = new Y.XmlText()
second.insertAfter(null, [deepsecond1])
second.insertAfter(deepsecond1, [deepsecond2])
yxml.insertAfter(null, [first, second])
yxml.insertAfter(second, [third])
t.assert(yxml.length === 3)
t.assert(second.get(0) === deepsecond1)
t.assert(second.get(1) === deepsecond2)
t.compareArrays(yxml.toArray(), [first, second, third])
t.fails(() => {
const el = new Y.XmlElement('p')
el.insertAfter(deepsecond1, [new Y.XmlText()])
})
}
/**
* @param {t.TestCase} tc
*/
export const testClone = tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText('text')
const second = new Y.XmlElement('p')
const third = new Y.XmlElement('p')
yxml.push([first, second, third])
t.compareArrays(yxml.toArray(), [first, second, third])
const cloneYxml = yxml.clone()
ydoc.getArray('copyarr').insert(0, [cloneYxml])
t.assert(cloneYxml.length === 3)
t.compare(cloneYxml.toJSON(), yxml.toJSON())
}
/**
* @param {t.TestCase} tc
*/
export const testFormattingBug = tc => {
const ydoc = new Y.Doc()
const yxml = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
const delta = [
{ insert: 'A', attributes: { em: {}, strong: {} } },
{ insert: 'B', attributes: { em: {} } },
{ insert: 'C', attributes: { em: {}, strong: {} } }
]
yxml.applyDelta(delta)
t.compare(yxml.toDelta(), delta)
}