Compare commits

..

137 Commits

Author SHA1 Message Date
Kevin Jahns
4f2d13e3ce 13.5.2 2021-03-11 18:54:02 +01:00
Kevin Jahns
e0b76cd2f4 [UndoManager] stop tracking unrelated insertions - yjs/y-monaco#10 2021-03-11 18:52:35 +01:00
Kevin Jahns
d812636c5b Implement doc suggestions - closes #249 2021-03-03 12:20:53 +01:00
Kevin Jahns
21fee0fe96 spelling issue lib0 repetition 2021-02-28 16:09:54 +01:00
Kevin Jahns
fab14a09de 13.5.1 2021-02-20 22:21:06 +01:00
Kevin Jahns
710b4ba145 upgrade typescript to v4 2021-02-20 22:19:22 +01:00
Kevin Jahns
34091ae614 add conditional exports 2021-02-20 21:55:01 +01:00
Kevin Jahns
feb8ec1afc catch errors from sponsoring message 2021-02-20 21:45:52 +01:00
Kevin Jahns
ce9139c9f4 Merge pull request #278 from BitPhinix/patch-1
Add slate-yjs to bindings
2021-02-18 17:56:21 +01:00
Eric Meier
e2e5d0870c Add slate-yjs to bindings
Adds slate-yjs to the bindings inside in the readme.
2021-02-14 13:04:57 +01:00
Kevin Jahns
04cff60931 add performance test for updates 2021-02-08 13:46:22 +01:00
Kevin Jahns
5dfe4e8af2 add documentation for differential updates 2021-02-08 12:40:00 +01:00
Kevin Jahns
05ca0b0208 13.5.0 2021-02-08 11:50:35 +01:00
Kevin Jahns
ee7c189fdc fix formatting issue #275 #277 2021-02-08 11:45:26 +01:00
Kevin Jahns
01c08ef202 Merge branch 'QortexDevs-merge-empty-lines-despite-attributes' into main 2021-02-08 11:09:24 +01:00
Kevin Jahns
894c0d7731 resolve conflicts 2021-02-08 11:09:13 +01:00
Kevin Jahns
fdf632f03e Merge pull request #274 from yjs/differential-updates-263
Differential updates
2021-02-07 23:58:59 +01:00
Kevin Jahns
ce80cb4a0d 13.4.14 2021-02-02 15:52:37 +01:00
Kevin Jahns
ae3c4cc050 add testHelper to bundle 2021-02-02 15:50:22 +01:00
Kevin Jahns
27a78047c5 13.4.13 2021-02-02 15:12:23 +01:00
Kevin Jahns
7a128c271b add changedParentTypes to undomanager events 2021-02-02 15:09:42 +01:00
Николай Митин
263cc0856e Implemented bug test 2021-01-31 18:17:10 +03:00
Kevin Jahns
2199ac3e4e merge relativePosition updates 2021-01-30 00:12:01 +01:00
Kevin Jahns
275d52b19d implement diffUpdates with tests - #263 2021-01-29 18:18:29 +01:00
Kevin Jahns
7edbb2485f complete refactor of update mechanism to allow encoding of pending updates - #263 2021-01-28 20:28:30 +01:00
Kevin Jahns
304812fb07 concept for improved implementation of pending updates 2021-01-17 15:22:36 +01:00
Kevin Jahns
baca852733 add relevant relative positions exports 2021-01-13 01:16:21 +01:00
Kevin Jahns
7cbf204143 reduce bundle size #272 2021-01-10 15:13:19 +01:00
Kevin Jahns
c8a59118b5 13.4.12 2021-01-10 12:37:08 +01:00
Kevin Jahns
bee397f1e5 rename funding exec 2021-01-10 12:31:01 +01:00
Kevin Jahns
1e97cf8323 bump dependencies & update npm website 2021-01-10 12:27:37 +01:00
Kevin Jahns
c28ad0608e emit transaction on update call 2021-01-10 12:19:44 +01:00
Kevin Jahns
e19f16f22c Merge branch 'main' of github.com:yjs/yjs into main 2021-01-10 00:17:30 +01:00
Kevin Jahns
6f074a873d add bunch of tests for relative positions 2021-01-10 00:16:18 +01:00
Kevin Jahns
4af04d6a29 fix associative relative positions 2021-01-10 00:01:56 +01:00
Kevin Jahns
97d9714710 13.4.11 2021-01-09 15:01:52 +01:00
Kevin Jahns
ca667be68b proper updating of text-attributes 2021-01-09 14:59:56 +01:00
Kevin Jahns
8086a4f816 13.4.10 2021-01-09 14:58:21 +01:00
Kevin Jahns
186f7140b6 fix #271 - multiline text formatting issue 2021-01-09 14:55:37 +01:00
Kevin Jahns
edc1f9418f reproduce #271 2021-01-09 14:45:51 +01:00
Kevin Jahns
32b734b24d add tests 2021-01-08 23:03:44 +01:00
Kevin Jahns
656328631c first prototype of associative relative positions (left- or right-associative) 2021-01-08 23:03:16 +01:00
Kevin Jahns
dbd1b3cb59 add tests for meta decoding of updates and state vector comparison of update and ydoc approach 2020-12-30 20:21:14 +01:00
Kevin Jahns
8fadec4dcd add test for merging via Y.Doc instance (should encode pending updates as well) 2020-12-30 19:32:00 +01:00
Kevin Jahns
8013b4ef5c lint 2020-12-29 17:07:25 +01:00
Kevin Jahns
0a40b541e8 test with all encoders 2020-12-29 16:59:27 +01:00
Kevin Jahns
728bb6f1b2 13.4.9 2020-12-22 17:23:25 +01:00
Kevin Jahns
fd59696b9a change funding url format 2020-12-22 17:21:07 +01:00
Kevin Jahns
bfacd2e63a 13.4.8 2020-12-22 17:15:30 +01:00
Kevin Jahns
6bc9c220b9 add funding to package.json 2020-12-22 17:12:46 +01:00
Kevin Jahns
7c0b98bbb2 lint 2020-12-22 17:05:36 +01:00
Kevin Jahns
034463798d deprecate toJSON 2020-12-22 17:04:31 +01:00
Kevin Jahns
4c929c6808 lint & refactoring 2020-12-19 16:29:17 +01:00
Kevin Jahns
0fc213e92e Merge branch 'main' into differential-updates-263 2020-12-18 22:02:54 +01:00
Kevin Jahns
bbc688975d improve funding message 2020-12-18 21:38:21 +01:00
Kevin Jahns
ab9373c188 funding message 2020-12-18 16:17:38 +01:00
Kevin Jahns
af576788f1 Merge branch 'main' into differential-updates-263 2020-12-18 02:04:31 +01:00
Kevin Jahns
fbbf085278 add mergeUpdates tests to comparison framework 2020-12-17 21:50:39 +01:00
Kevin Jahns
d8868c47e1 test case for deletes + fix 2020-12-16 23:45:28 +01:00
Kevin Jahns
47221c26c4 test with v1 and v2 encoding 2020-12-16 23:26:38 +01:00
Kevin Jahns
ba83398374 fix tests 2020-12-16 22:58:22 +01:00
Kevin Jahns
0b23d5aeeb First working version of differential updates - #263 2020-12-16 22:53:11 +01:00
Kevin Jahns
072947c0bb implement update logging 2020-12-16 21:25:00 +01:00
Kevin Jahns
22aef63d8a add Skip struct 2020-12-16 21:08:18 +01:00
Kevin Jahns
f8341220c3 first working version that also considers holes in document updates - #263 2020-12-15 15:39:08 +01:00
Kevin Jahns
50e5964fcb Merge pull request #268 from jsilvao/main
Update README.md
2020-12-14 20:39:48 +01:00
Kevin Jahns
004a781a56 basic merge works. fixes first test #263 2020-12-13 16:24:43 +01:00
Javier Silva Ortíz
31dee48f63 Update README.md
Add a new Yjs user
2020-12-12 17:37:07 -05:00
Kevin Jahns
c8534ea6bc merging delete-sets #263 2020-12-12 22:48:10 +01:00
Kevin Jahns
1e0fd60df4 proper merge for deletesets 2020-12-12 22:40:55 +01:00
Kevin Jahns
3404d22d12 13.4.7 2020-12-12 21:41:08 +01:00
Kevin Jahns
d3b56702ad Merge pull request #267 from yjs/wishlist-259
Implements some features of wishlist #259
2020-12-12 21:38:45 +01:00
Kevin Jahns
d5e6c26420 Merge branch 'main' into wishlist-259 2020-12-12 21:36:45 +01:00
Kevin Jahns
e497f07f7a remove new pos api template 2020-12-12 21:33:14 +01:00
Kevin Jahns
510354d99f add github workflow 2020-12-12 21:22:55 +01:00
Kevin Jahns
c3342d0b34 Merge pull request #266 from yjs/circleci-project-setup
Add .circleci/config.yml
2020-12-12 21:19:41 +01:00
Kevin Jahns
45af21f31e Add .circleci/config.yml 2020-12-12 21:18:14 +01:00
Kevin Jahns
320da29b69 implement merge-logic - #263 2020-12-10 18:06:35 +01:00
Kevin Jahns
783c4d8209 write #263 append logic 2020-12-09 17:48:45 +01:00
Kevin Jahns
2c708b647d write lazy encoder & decoder - #263 2020-12-08 20:20:40 +01:00
Kevin Jahns
7a45be8c88 add merge tests for #263 2020-12-07 19:47:48 +01:00
Kevin Jahns
972d15dda5 Update Sponsors 2020-12-05 13:17:14 +01:00
Kevin Jahns
fdf2063943 13.4.6 2020-12-04 14:02:53 +01:00
Kevin Jahns
e81267d4df implement correct destroy event 2020-12-04 14:01:14 +01:00
Kevin Jahns
563c34f81a Update README.md 2020-12-01 15:50:58 +01:00
Kevin Jahns
ba713983e3 update sponsors 2020-12-01 15:41:45 +01:00
Kevin Jahns
bf2ee3680b 13.4.5 2020-11-21 19:28:56 +01:00
Kevin Jahns
b812a3dd6c Add getItem to the exports 2020-11-21 19:27:12 +01:00
Kevin Jahns
b3f5b50377 Merge branch 'wishlist-259' of github.com:yjs/yjs into wishlist-259 2020-11-16 12:40:27 +01:00
Kevin Jahns
7bcd4a828d Create new Pos API - #259 2020-11-16 12:40:18 +01:00
Kevin Jahns
cb705922b4 implement insertAfter - #259 2020-11-15 14:57:45 +01:00
Kevin Jahns
1ed58909d3 implement prev/nextSibling&firstChild & parent - #259 2020-11-14 13:33:43 +01:00
Kevin Jahns
0aca7bbefa implement attributes on Y.Text 2020-11-13 12:40:53 +01:00
Kevin Jahns
e1f0324840 call UndoManager pop-stack-item after transaction 2020-11-13 12:05:53 +01:00
Kevin Jahns
7bac783490 13.4.4 2020-11-08 13:09:49 +01:00
Kevin Jahns
1508c44f68 lint 2020-11-08 13:08:14 +01:00
Kevin Jahns
3dd843372f Merge pull request #254 from nornagon/array-from
add Y.Array.from
2020-11-08 02:01:48 +01:00
Kevin Jahns
d6be4d9391 Merge pull request #253 from lpmi-13/update_links
update http links, where possible, to https
2020-11-08 02:00:36 +01:00
Kevin Jahns
53f2344017 implement .clone, .slice, and yxml.get 2020-11-08 01:51:39 +01:00
Kevin Jahns
86f7631d1e 13.4.3 2020-11-04 00:37:24 +01:00
Kevin Jahns
3bb107504f fix superflous event happening in nested event system 2020-11-04 00:35:08 +01:00
Jeremy Rose
4c46ebfb45 add Y.Array.from 2020-11-01 10:01:04 -08:00
Adam Leskis
9d0d63ead7 update http links, where possible, to https
cattaz.io, unfortunately, is still only available over http, but I've raised an issue in the repo to enable https on github pages, which the site appears to be using.
2020-10-31 10:32:05 +00:00
Kevin Jahns
39803c1d11 13.4.2 2020-10-31 03:58:59 +01:00
Kevin Jahns
46fae57036 Merge pull request #244 from hanspagel/patch-1
fix a small typo (at it heart -> at its heart)
2020-10-31 03:51:17 +01:00
Kevin Jahns
e9cb07da55 Failsafe when splitting surrogate pairs - fixes #248 2020-10-31 02:05:33 +01:00
Kevin Jahns
114f28f48e log error when removing eventhandler that doesnt exist - implements #246 2020-10-31 00:34:19 +01:00
Kevin Jahns
a1da486c8a Merge branch 'main' of github.com:yjs/yjs into main 2020-10-29 12:40:48 +01:00
Kevin Jahns
4fb9cc2a30 fire top-level events first 2020-10-29 12:40:39 +01:00
Kevin Jahns
e2c9eb7f01 13.4.1 2020-10-10 16:53:31 +02:00
Kevin Jahns
6fd33c0720 fix permanent user-data init with new DS-decoder - fixes yjs/y-websocket#33 2020-10-10 16:48:43 +02:00
Hans Pagel
72f3ce75b2 fix a small typo (at it heart -> at its heart) 2020-09-28 23:29:09 +02:00
Kevin Jahns
fd211731cc 13.4.0 2020-09-28 19:04:58 +02:00
Kevin Jahns
8049776074 fix double undo - fixes #241 2020-09-28 19:00:13 +02:00
Kevin Jahns
32b1338d48 Merge pull request #233 from rideg/add_typing_232
Amend typing of YEvent.changes, fixes #232
2020-09-28 18:38:03 +02:00
Kevin Jahns
c2f0ca3fae Merge pull request #238 from johnrees/patch-1
Fix typo in README example
2020-09-28 18:36:35 +02:00
Kevin Jahns
dfc6b879de Merge pull request #239 from yjs/subdocs
implemented first subdocuments draft #234
2020-09-28 18:35:43 +02:00
Kevin Jahns
81f16ff0b5 Merge pull request #243 from yjs/create-doc-from-snapshot-3
Create doc from snapshot 3
2020-09-28 18:34:01 +02:00
Kevin Jahns
e1a2ccd7f6 add tests to snapshots case and fix the case of empty ranges 2020-09-28 18:32:24 +02:00
Kevin Jahns
be8cc8a20c Merge branch '159-create-doc-from-snapshot-2' of git://github.com/calibr/yjs into calibr-159-create-doc-from-snapshot-2 2020-09-28 17:57:51 +02:00
Kevin Jahns
a253cfc090 Merge pull request #235 from DeepAnchor/patch-1
Fix JSDoc annotation
2020-09-28 17:55:25 +02:00
Kevin Jahns
992c0b5e32 13.4.0-0 2020-09-10 01:57:00 +02:00
Kevin Jahns
e17d661769 implemented first subdocuments draft #234 2020-09-10 01:54:16 +02:00
calibr
fef3fc2a4a remove debug messages 2020-09-08 13:33:41 +03:00
calibr
eee695eeeb use encoding/decoding for restoring snapshots 2020-09-08 13:32:02 +03:00
John Rees
38e38a92dc Fix typo in README example 2020-09-04 11:30:01 +01:00
Kevin Jahns
dadc08597d Merge branch 'josephg-main' into main 2020-09-03 19:14:39 +02:00
Kevin Jahns
e769a2a354 Finishing up INTERNALS.md 2020-09-03 19:14:19 +02:00
Seph Gentle
0dd0a4be14 Added draft of INTERNALS.js describing how Yjs works 2020-09-03 10:02:04 +10:00
DeepAnchor
7193ae63b7 Fix JSDoc annotation 2020-08-25 13:09:34 -07:00
rideg
4d48224518 Add typing 2020-08-24 09:57:38 -07:00
Kevin Jahns
b4fc073aa5 properly annotate DeltaItem.insert - fixes #227 2020-08-08 18:29:50 +02:00
Kevin Jahns
9c0d1eb209 Merge branch '159-create-doc-from-snapshot-2' of git://github.com/calibr/yjs into calibr-159-create-doc-from-snapshot-2 2020-08-08 12:03:50 +02:00
Kevin Jahns
6a9f853d12 fix readme formatting 2020-08-08 02:43:03 +02:00
Kevin Jahns
ce3b0f3043 13.3.2 2020-08-07 19:31:29 +02:00
Kevin Jahns
94646b2f45 fix item.content undefined 2020-08-07 19:29:08 +02:00
calibr
ceba4b1837 restoring document to a specific state using a Snapshot, #159 2020-07-27 03:56:32 +03:00
57 changed files with 3256 additions and 866 deletions

7
.circleci/config.yml Normal file
View File

@@ -0,0 +1,7 @@
version: 2.1
orbs:
node: circleci/node@3.0.0
workflows:
node-tests:
jobs:
- node/test

29
.github/workflows/node.js.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
# 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

179
INTERNALS.md Normal file
View File

@@ -0,0 +1,179 @@
# 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` heirachy (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 know state of each user (a set of
tubles `(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`. I 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.

154
README.md
View File

@@ -31,29 +31,30 @@ 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/)
[![davidhq](https://github.com/davidhq.png?size=60)](https://github.com/davidhq)
[![Ifiok Jr.](https://github.com/ifiokjr.png?size=60)](https://github.com/ifiokjr)
[![Burke Libbey](https://github.com/burke.png?size=60)](https://github.com/burke)
[![Beni Cherniavsky-Paskin](https://github.com/cben.png?size=60)](https://github.com/cben)
[![Tom Moor](https://github.com/tommoor.png?size=60)](https://github.com/tommoor)
[![Michael Meyers](https://github.com/michaelemeyers.png?size=60)](https://github.com/michaelemeyers)
[![Cristiano Benjamin](https://github.com/csbenjamin.png?size=60)](https://github.com/csbenjamin)
[![Braden](https://github.com/AdventureBeard.png?size=60)](https://github.com/AdventureBeard)
[![nimbuswebinc](https://nimbusweb.me/new-style-img/note-icon.svg)](https://github.com/nimbuswebinc)
[![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)
[![ifiokjr](https://github.com/ifiokjr.png?size=60)](https://github.com/ifiokjr)
[![mrfambo](https://github.com/mrfambo.png?size=60)](https://github.com/mrfambo)
[<img src="https://room.sh/img/icons/android-chrome-192x192.png" height="60px" />](https://room.sh/)
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
* [Relm](http://www.relm.us/) A collaborative gameworld for teamwork and
* [Relm](https://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/)
* [https://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:
@@ -64,6 +65,8 @@ Sponsorship also comes with special perks! [![Become a Sponsor](https://img.shie
collaboratively organize radio broadcasts.
* [Cattaz](http://cattaz.io/) A wiki that can run custom applications in the
wiki pages.
* [Alldone](https://alldoneapp.com/) A next-gen project management and
collaboration platform.
## Table of Contents
@@ -90,10 +93,11 @@ 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](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) |
| [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) |
### Providers
@@ -103,7 +107,7 @@ manage all that for you and are the perfect starting point for your
collaborative app.
<dl>
<dt><a href="http://github.com/yjs/y-webrtc">y-webrtc</a></dt>
<dt><a href="https://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
@@ -111,19 +115,19 @@ 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="http://github.com/yjs/y-websocket">y-websocket</a></dt>
<dt><a href="https://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="http://github.com/yjs/y-indexeddb">y-indexeddb</a></dt>
<dt><a href="https://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="http://github.com/yjs/y-dat">y-dat</a></dt>
<dt><a href="https://github.com/yjs/y-dat">y-dat</a></dt>
<dd>
[WIP] Write document updates effinciently to the dat network using
<a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has
@@ -198,7 +202,7 @@ const ydoc = new Y.Doc()
// this allows you to instantly get the (cached) documents data
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
idbP.whenSynced.then(() => {
indexeddbProvider.whenSynced.then(() => {
console.log('loaded data from indexed db')
})
@@ -241,6 +245,8 @@ 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|Uint8Array|Y.Type&gt;)</code></b>
<dd>
Insert content at <var>index</var>. Note that content is an array of elements.
@@ -255,6 +261,8 @@ position 0.
<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|Uint8Array|Y.Type&gt;</code></b>
<dd>Retrieve a range of content</dd>
<b><code>length:number</code></b>
<dd></dd>
<b>
@@ -310,6 +318,10 @@ or any of its children.
</p>
<pre><code>const ymap = new Y.Map()</code></pre>
<dl>
<b><code>parent:Y.AbstractType|null</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|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
@@ -320,6 +332,8 @@ or any of its children.
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></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|Uint8Array&gt;</code></b>
<dd>
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
@@ -387,6 +401,8 @@ 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.
@@ -396,7 +412,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, opts:Object&lt;string,any&gt;)</code></b>
<b><code>applyDelta(delta: 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.
@@ -445,14 +461,22 @@ 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>
@@ -461,6 +485,8 @@ 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
@@ -496,6 +522,14 @@ 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>
@@ -510,8 +544,14 @@ content and be actually XML compliant.
<dd></dd>
<b><code>getAttribute(attributeName:string):string</code></b>
<dd></dd>
<b><code>getAttributes(attributeName:string):Object&lt;string,string&gt;</code></b>
<b><code>getAttributes():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>
@@ -571,9 +611,12 @@ triggers a single change event. <br>You can specify an optional <code>origin</co
parameter that is stored on <code>transaction.origin</code> and
<code>on('update', (update, origin) => ..)</code>.
</dd>
<b><code>toJSON():any</code><b>
<b><code>toJSON():any</code></b>
<dd>
Converts the entire document into a js object, recursively traversing each yjs type.
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>
@@ -666,6 +709,30 @@ 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])
```
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>
@@ -682,22 +749,26 @@ 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>
</dl>
### Relative Positions
> This API is not stable yet
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
```js
@@ -892,11 +963,14 @@ 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).
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.
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).
CRDTs suitable for shared text editing suffer from the fact that they only grow
CRDTs that suitable for shared text editing suffer from the fact that they only grow
in size. There are CRDTs that do not grow in size, but they do not have the
characteristics that are benificial for shared text editing (like intention
preservation). Yjs implements many improvements to the original algorithm that

198
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.3.1",
"version": "13.5.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -31,59 +31,95 @@
}
},
"@babel/parser": {
"version": "7.10.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.2.tgz",
"integrity": "sha512-PApSXlNMJyB4JiGVhCOlzKIif+TKFTvu0aQAhnTvfP/z3vVSN6ZypH5bfUNwFXXjRQtUEBNFd2PtmCmG2Py3qQ==",
"version": "7.12.15",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.15.tgz",
"integrity": "sha512-AQBOU2Z9kWwSZMd6lNjCX0GUgFonL1wAM1db8L8PMk9UDaGsRCArBkU4Sc+UCM3AE4hjbXx+h58Lb3QT4oRmrA==",
"dev": true
},
"@rollup/plugin-commonjs": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-11.1.0.tgz",
"integrity": "sha512-Ycr12N3ZPN96Fw2STurD21jMqzKwL9QuFhms3SD7KKRK7oaXUsBU9Zt0jL/rOPHiPYisI21/rXGO3jr9BnLHUA==",
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz",
"integrity": "sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.0.8",
"@rollup/pluginutils": "^3.1.0",
"commondir": "^1.0.1",
"estree-walker": "^1.0.1",
"glob": "^7.1.2",
"is-reference": "^1.1.2",
"magic-string": "^0.25.2",
"resolve": "^1.11.0"
"estree-walker": "^2.0.1",
"glob": "^7.1.6",
"is-reference": "^1.2.1",
"magic-string": "^0.25.7",
"resolve": "^1.17.0"
},
"dependencies": {
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"resolve": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
"dev": true,
"requires": {
"is-core-module": "^2.1.0",
"path-parse": "^1.0.6"
}
}
}
},
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
"integrity": "sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.0.tgz",
"integrity": "sha512-qHjNIKYt5pCcn+5RUBQxK8krhRvf1HnyVgUCcFFcweDS7fhkOLZeYh0mhHK6Ery8/bb9tvN/ubPzmfF0qjDCTA==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.0.8",
"@types/resolve": "0.0.8",
"@rollup/pluginutils": "^3.1.0",
"@types/resolve": "1.17.1",
"builtin-modules": "^3.1.0",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.14.2"
"resolve": "^1.19.0"
},
"dependencies": {
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
"integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
"integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
"dev": true,
"requires": {
"is-core-module": "^2.2.0",
"path-parse": "^1.0.6"
}
}
}
},
"@rollup/pluginutils": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.10.tgz",
"integrity": "sha512-d44M7t+PjmMrASHbhgpSbVgtL6EFyX7J4mYxwQ/c5eoaE6N2VgCgEcWVzNnwycIloti+/MpwFr8qfw+nRw00sw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dev": true,
"requires": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
},
"dependencies": {
"estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"dev": true
}
}
},
"@types/color-name": {
@@ -99,15 +135,15 @@
"dev": true
},
"@types/node": {
"version": "14.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.9.tgz",
"integrity": "sha512-0sCTiXKXELOBxvZLN4krQ0FPOAA7ij+6WwvD0k/PHd9/KAkr4dXel5J9fh6F4x1FwAQILqAWkmpeuS6mjf1iKA==",
"version": "14.14.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz",
"integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==",
"dev": true
},
"@types/resolve": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz",
"integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==",
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
"dev": true,
"requires": {
"@types/node": "*"
@@ -239,9 +275,9 @@
}
},
"builtin-modules": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz",
"integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz",
"integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==",
"dev": true
},
"callsites": {
@@ -458,6 +494,12 @@
"integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
"dev": true
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"dev": true
},
"define-properties": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -946,9 +988,9 @@
"dev": true
},
"estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"esutils": {
@@ -1057,6 +1099,13 @@
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@@ -1342,6 +1391,15 @@
"integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==",
"dev": true
},
"is-core-module": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
"integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
"dev": true,
"requires": {
"has": "^1.0.3"
}
},
"is-date-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
@@ -1376,20 +1434,12 @@
"dev": true
},
"is-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.0.tgz",
"integrity": "sha512-ZVxq+5TkOx6GQdnoMm2aRdCKADdcrOWXLGzGT+vIA8DMpqEJaRk5AL1bS80zJ2bjHunVmjdzfCt0e4BymIEqKQ==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"requires": {
"@types/estree": "0.0.44"
},
"dependencies": {
"@types/estree": {
"version": "0.0.44",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.44.tgz",
"integrity": "sha512-iaIVzr+w2ZJ5HkidlZ3EJM8VTZb2MJLCjw3V+505yVts0gRC4UMvjw0d1HPtGqI/HQC/KdsYtayfzl+AXY2R8g==",
"dev": true
}
"@types/estree": "*"
}
},
"is-regex": {
@@ -1429,9 +1479,9 @@
"dev": true
},
"isomorphic.js": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.4.tgz",
"integrity": "sha512-t9zbgkjE7f9f2M6OSW49YEq0lUrSdAllBbWFUZoeck/rnnFae6UlhmDtXWs48VJY3ZpryCoZsRiAiKD44hPIGQ=="
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.0.tgz",
"integrity": "sha512-U7JlVUbmVYvV1ddGLhc7pvoMNp+1uGwE3sCIGHEh9I4ldSJJ9LtLog5/H8GzZkwLjmbeEvIbQb50nsre57nDuw=="
},
"js-tokens": {
"version": "4.0.0",
@@ -1459,9 +1509,9 @@
}
},
"jsdoc": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.4.tgz",
"integrity": "sha512-3G9d37VHv7MFdheviDCjUfQoIjdv4TC5zTTf5G9VODLtOnVS6La1eoYBDlbWfsRT3/Xo+j2MIqki2EV12BZfwA==",
"version": "3.6.6",
"resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.6.tgz",
"integrity": "sha512-znR99e1BHeyEkSvgDDpX0sTiTu+8aQyDl9DawrkOGZTTW8hv0deIFXx87114zJ7gRaDZKVQD/4tr1ifmJp9xhQ==",
"dev": true,
"requires": {
"@babel/parser": "^7.9.4",
@@ -1548,11 +1598,11 @@
}
},
"lib0": {
"version": "0.2.32",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.32.tgz",
"integrity": "sha512-cHHKhHTojtvFSsthTk+CKuD17jMHIxuZxYpTzXj9TeQLPNoGNDPl6ax+J6eFETVe3ZvPMh3V0nGfJgGo6QgSvA==",
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.38.tgz",
"integrity": "sha512-ZxnX62R5weebi8bH/Ipc6JBiQIsiQ1D7p3r96zulSSu1byW6DDWSBeI8WC/W5UGtkZ80GktX3JNY2pqhNiXWGA==",
"requires": {
"isomorphic.js": "^0.1.3"
"isomorphic.js": "^0.2.0"
}
},
"linkify-it": {
@@ -2380,22 +2430,14 @@
}
},
"rollup": {
"version": "1.32.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-1.32.1.tgz",
"integrity": "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==",
"version": "2.39.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.39.0.tgz",
"integrity": "sha512-+WR3bttcq7zE+BntH09UxaW3bQo3vItuYeLsyk4dL2tuwbeSKJuvwiawyhEnvRdRgrII0Uzk00FpctHO/zB1kw==",
"dev": true,
"requires": {
"@types/estree": "*",
"@types/node": "*",
"acorn": "^7.1.0"
"fsevents": "~2.3.1"
}
},
"rollup-cli": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/rollup-cli/-/rollup-cli-1.0.9.tgz",
"integrity": "sha1-N/ShwgYxHikuMpfql3eduKIduZQ=",
"dev": true
},
"run-async": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
@@ -2786,9 +2828,9 @@
"dev": true
},
"typescript": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.6.tgz",
"integrity": "sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
"integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
"dev": true
},
"uc.micro": {
@@ -2898,12 +2940,12 @@
"dev": true
},
"y-protocols": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-0.2.3.tgz",
"integrity": "sha512-mJ838iW7XgMQqlv+9DtH7QyLqflZoy/VvaUWRIpwawee4mQiFJcEXazCmSYUHEbXIUuVNNc70FnuNSMWDC5vKQ==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.4.tgz",
"integrity": "sha512-5/Hd6DJ5Y2SlbqLIKq86BictdOS0iAcWJZCVop8MKqx0XWwA+BbMn4538n4Z0CGjFMUGnG1kGzagk3BKGz5SvQ==",
"dev": true,
"requires": {
"lib0": "^0.2.20"
"lib0": "^0.2.35"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.3.1",
"version": "13.5.2",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
@@ -12,26 +12,35 @@
"url": "https://github.com/sponsors/dmonad"
},
"scripts": {
"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",
"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",
"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 --repitition-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 --repetition-time 1000 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
"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"
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs",
"postinstall": "node ./sponsor-y.js"
},
"exports": {
".": {
"import": "./dist/yjs.mjs",
"require": "./dist/yjs.cjs"
},
"./src/index.js": "./src/index.js",
"./tests/testHelper.js": "./tests/testHelper.js"
},
"files": [
"dist/*",
"src/*",
"tests/*",
"docs/*"
"dist/yjs.*",
"dist/src",
"src",
"tests/testHelper.js",
"sponsor-y.js"
],
"dictionaries": {
"doc": "docs",
"test": "tests"
},
"standard": {
@@ -59,22 +68,21 @@
"bugs": {
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "https://yjs.dev",
"homepage": "https://docs.yjs.dev",
"dependencies": {
"lib0": "^0.2.32"
"lib0": "^0.2.38"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.1.3",
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.2.0",
"concurrently": "^3.6.1",
"http-server": "^0.12.3",
"jsdoc": "^3.6.4",
"jsdoc": "^3.6.6",
"markdownlint-cli": "^0.23.2",
"rollup": "^1.32.1",
"rollup-cli": "^1.0.9",
"rollup": "^2.39.0",
"standard": "^14.3.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^3.9.6",
"y-protocols": "^0.2.3"
"typescript": "^4.1.5",
"y-protocols": "^1.0.4"
}
}

12
sponsor-y.js Normal file
View File

@@ -0,0 +1,12 @@
try {
const log = require('lib0/dist/logging.cjs')
log.print()
log.print(log.BOLD, log.GREEN, log.BOLD, 'Thank you for using Yjs ', log.RED, '❤\n')
log.print(
log.GREY,
'The project has grown considerably in the past year. Too much for me to maintain\nin my spare time. Several companies built their products with Yjs.\nYet, this project receives very little funding. Yjs is far from done. I want to\ncreate more awesome extensions and work on the growing number of open issues.\n', log.BOLD, 'Dear user, the future of this project entirely depends on you.\n')
log.print(log.BLUE, log.BOLD, 'Please start funding the project now: https://github.com/sponsors/dmonad \n')
log.print(log.GREY, '(This message will be removed when I achieved my funding goal)\n\n')
} catch (e) { }

View File

@@ -32,8 +32,6 @@ export {
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
writeRelativePosition,
readRelativePosition,
ID,
createID,
compareIDs,
@@ -45,8 +43,10 @@ export {
snapshot,
emptySnapshot,
findRootTypeKey,
getItem,
typeListToArraySnapshot,
typeMapGetSnapshot,
createDocFromSnapshot,
iterateDeletedStructs,
applyUpdate,
applyUpdateV2,
@@ -55,14 +55,15 @@ export {
encodeStateAsUpdate,
encodeStateAsUpdateV2,
encodeStateVector,
encodeStateVectorV2,
UndoManager,
decodeSnapshot,
encodeSnapshot,
decodeSnapshotV2,
encodeSnapshotV2,
decodeStateVector,
decodeStateVectorV2,
logUpdate,
logUpdateV2,
relativePositionToJSON,
isDeleted,
isParentOf,
equalSnapshots,
@@ -70,5 +71,15 @@ export {
tryGc,
transact,
AbstractConnector,
logType
logType,
mergeUpdates,
mergeUpdatesV2,
parseUpdateMeta,
parseUpdateMetaV2,
encodeStateVectorFromUpdate,
encodeStateVectorFromUpdateV2,
encodeRelativePosition,
decodeRelativePosition,
diffUpdate,
diffUpdateV2
} from './internals.js'

View File

@@ -15,6 +15,7 @@ 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'
@@ -31,6 +32,7 @@ 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'
@@ -38,3 +40,4 @@ 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,6 +1,6 @@
import {
AbstractUpdateEncoder, ID, Transaction // eslint-disable-line
UpdateEncoderV1, UpdateEncoderV2, ID, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
@@ -34,7 +34,7 @@ export class AbstractStruct {
}
/**
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {number} offset
* @param {number} encodingRef
*/

View File

@@ -1,5 +1,5 @@
import {
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
UpdateEncoderV1, UpdateEncoderV2, UpdateDecoderV1, UpdateDecoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
export class ContentAny {
@@ -74,7 +74,7 @@ export class ContentAny {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -95,7 +95,7 @@ export class ContentAny {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentAny}
*/
export const readContentAny = decoder => {

View File

@@ -1,5 +1,5 @@
import {
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
@@ -70,7 +70,7 @@ export class ContentBinary {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -86,7 +86,7 @@ export class ContentBinary {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @return {ContentBinary}
*/
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())

View File

@@ -1,7 +1,7 @@
import {
addToDeleteSet,
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
export class ContentDeleted {
@@ -77,7 +77,7 @@ export class ContentDeleted {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -95,7 +95,7 @@ export class ContentDeleted {
/**
* @private
*
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @return {ContentDeleted}
*/
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())

135
src/structs/ContentDoc.js Normal file
View File

@@ -0,0 +1,135 @@
import {
Doc, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
/**
* @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(this.doc)
}
/**
* @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(new Doc({ guid: decoder.readString(), ...decoder.readAny() }))

View File

@@ -1,6 +1,6 @@
import {
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
@@ -74,7 +74,7 @@ export class ContentEmbed {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -92,7 +92,7 @@ export class ContentEmbed {
/**
* @private
*
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentEmbed}
*/
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())

View File

@@ -1,6 +1,6 @@
import {
AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Item, StructStore, Transaction // eslint-disable-line
AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Item, StructStore, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
@@ -80,7 +80,7 @@ export class ContentFormat {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -97,7 +97,7 @@ export class ContentFormat {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentFormat}
*/
export const readContentFormat = decoder => new ContentFormat(decoder.readString(), decoder.readJSON())

View File

@@ -1,5 +1,5 @@
import {
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
/**
@@ -77,7 +77,7 @@ export class ContentJSON {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -100,7 +100,7 @@ export class ContentJSON {
/**
* @private
*
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentJSON}
*/
export const readContentJSON = decoder => {

View File

@@ -1,5 +1,5 @@
import {
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
/**
@@ -51,6 +51,17 @@ 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
}
@@ -77,7 +88,7 @@ export class ContentString {
*/
gc (store) {}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -95,7 +106,7 @@ export class ContentString {
/**
* @private
*
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentString}
*/
export const readContentString = decoder => new ContentString(decoder.readString())

View File

@@ -7,13 +7,13 @@ import {
readYXmlFragment,
readYXmlHook,
readYXmlText,
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
/**
* @type {Array<function(AbstractUpdateDecoder):AbstractType<any>>}
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractType<any>>}
* @private
*/
export const typeRefs = [
@@ -148,7 +148,7 @@ export class ContentType {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
@@ -166,7 +166,7 @@ export class ContentType {
/**
* @private
*
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentType}
*/
export const readContentType = decoder => new ContentType(typeRefs[decoder.readTypeRef()](decoder))

View File

@@ -2,7 +2,7 @@
import {
AbstractStruct,
addStruct,
AbstractUpdateEncoder, StructStore, Transaction, ID // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
export const structGCRefNumber = 0
@@ -22,6 +22,9 @@ export class GC extends AbstractStruct {
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
@@ -39,7 +42,7 @@ export class GC extends AbstractStruct {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {

View File

@@ -17,16 +17,15 @@ import {
readContentAny,
readContentString,
readContentEmbed,
readContentDoc,
createID,
readContentFormat,
readContentType,
addChangedTypeToTransaction,
AbstractUpdateDecoder, AbstractUpdateEncoder, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
@@ -555,6 +554,7 @@ 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) &&
@@ -593,7 +593,7 @@ export class Item extends AbstractStruct {
}
this.markDeleted()
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
this.content.delete(transaction)
}
}
@@ -620,7 +620,7 @@ export class Item extends AbstractStruct {
*
* This is called when this Item is sent to a remote peer.
*
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {number} offset
*/
write (encoder, offset) {
@@ -640,6 +640,7 @@ export class Item extends AbstractStruct {
}
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
@@ -651,6 +652,15 @@ export class Item extends AbstractStruct {
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)
} else {
error.unexpectedCase()
}
if (parentSub !== null) {
encoder.writeString(parentSub)
}
@@ -660,7 +670,7 @@ export class Item extends AbstractStruct {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {number} info
*/
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
@@ -668,18 +678,20 @@ export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS
/**
* A lookup map for reading Item content.
*
* @type {Array<function(AbstractUpdateDecoder):AbstractContent>}
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
*/
export const contentRefs = [
() => { throw error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted,
readContentJSON,
readContentBinary,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
readContentAny
() => { 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
]
/**
@@ -759,7 +771,7 @@ export class AbstractContent {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {

60
src/structs/Skip.js Normal file
View File

@@ -0,0 +1,60 @@
import {
AbstractStruct,
UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js'
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,7 +11,7 @@ import {
ContentAny,
ContentBinary,
getItemCleanStart,
YText, YArray, AbstractUpdateEncoder, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
@@ -287,6 +287,13 @@ export class AbstractType {
this._searchMarker = null
}
/**
* @return {AbstractType<any>|null}
*/
get parent () {
return this._item ? /** @type {AbstractType<any>} */ (this._item.parent) : null
}
/**
* Integrate this type into the Yjs instance.
*
@@ -310,7 +317,14 @@ export class AbstractType {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @return {AbstractType<EventType>}
*/
clone () {
throw error.methodUnimplemented()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) { }
@@ -381,6 +395,43 @@ 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>}
@@ -611,6 +662,10 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
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
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))
@@ -761,6 +816,9 @@ 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)

View File

@@ -15,8 +15,9 @@ import {
YArrayRefID,
callTypeObservers,
transact,
ArraySearchMarker, AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import { typeListSlice } from './AbstractType.js'
/**
* Event that describes the changes on a YArray
@@ -53,6 +54,18 @@ export class YArray extends AbstractType {
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
}
/**
* Integrate this type into the Yjs instance.
*
@@ -73,6 +86,17 @@ 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
}
@@ -167,6 +191,17 @@ 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.
*
@@ -206,7 +241,7 @@ export class YArray extends AbstractType {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YArrayRefID)
@@ -214,7 +249,7 @@ export class YArray extends AbstractType {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
*
* @private
* @function

View File

@@ -14,7 +14,7 @@ import {
YMapRefID,
callTypeObservers,
transact,
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as iterator from 'lib0/iterator.js'
@@ -84,6 +84,17 @@ export class YMap extends AbstractType {
return new YMap()
}
/**
* @return {YMap<T>}
*/
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.
*
@@ -132,9 +143,9 @@ export class YMap extends AbstractType {
}
/**
* Returns the keys for each element in the YMap Type.
* Returns the values for each element in the YMap Type.
*
* @return {IterableIterator<string>}
* @return {IterableIterator<any>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
@@ -227,7 +238,7 @@ export class YMap extends AbstractType {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YMapRefID)
@@ -235,7 +246,7 @@ export class YMap extends AbstractType {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
*
* @private
* @function

View File

@@ -21,8 +21,12 @@ import {
iterateDeletedStructs,
iterateStructs,
findMarker,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapGetAll,
updateMarkerChanges,
ArraySearchMarker, AbstractUpdateDecoder, AbstractUpdateEncoder, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as object from 'lib0/object.js'
@@ -160,11 +164,12 @@ const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes
}
const doc = transaction.doc
const ownClientId = doc.clientID
let left = currPos.left
let nextFormat = currPos.left
const right = currPos.right
negatedAttributes.forEach((val, key) => {
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)
nextFormat = new Item(createID(ownClientId, getState(doc.store, ownClientId)), nextFormat, nextFormat && nextFormat.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
nextFormat.integrate(transaction, 0)
currPos.right = nextFormat
})
}
@@ -501,7 +506,7 @@ const deleteText = (transaction, currPos, length) => {
* @typedef {Object} DeltaItem
* @property {number|undefined} DeltaItem.delete
* @property {number|undefined} DeltaItem.retain
* @property {string|undefined} DeltaItem.string
* @property {string|undefined} DeltaItem.insert
* @property {Object<string,any>} DeltaItem.attributes
*/
@@ -512,13 +517,32 @@ export class YTextEvent extends YEvent {
/**
* @param {YText} ytext
* @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed
*/
constructor (ytext, transaction) {
constructor (ytext, transaction, subs) {
super(ytext, transaction)
/**
* @type {Array<DeltaItem>|null}
*/
this._delta = null
/**
* Whether the children changed.
* @type {Boolean}
* @private
*/
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)
}
})
}
/**
@@ -762,6 +786,15 @@ 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.
*
@@ -770,7 +803,7 @@ export class YText extends AbstractType {
*/
_callObserver (transaction, parentSubs) {
super._callObserver(transaction, parentSubs)
const event = new YTextEvent(this, transaction)
const event = new YTextEvent(this, transaction, parentSubs)
const doc = transaction.doc
// If a remote change happened, we try to cleanup potential formatting duplicates.
if (!transaction.local) {
@@ -782,8 +815,7 @@ export class YText extends AbstractType {
continue
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
// @ts-ignore
if (item.content.constructor === ContentFormat) {
if (!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
@@ -1104,7 +1136,75 @@ export class YText extends AbstractType {
}
/**
* @param {AbstractUpdateEncoder} encoder
* 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
*/
_write (encoder) {
encoder.writeTypeRef(YTextRefID)
@@ -1112,7 +1212,7 @@ export class YText extends AbstractType {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YText}
*
* @private

View File

@@ -8,7 +8,7 @@ import {
typeMapGetAll,
typeListForEach,
YXmlElementRefID,
AbstractUpdateDecoder, AbstractUpdateEncoder, Snapshot, Doc, Item // eslint-disable-line
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Snapshot, Doc, Item // eslint-disable-line
} from '../internals.js'
/**
@@ -28,6 +28,22 @@ 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.
*
@@ -55,6 +71,20 @@ 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, el.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
@@ -178,7 +208,7 @@ export class YXmlElement extends YXmlFragment {
*
* This is called when this Item is sent to a remote peer.
*
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlElementRefID)
@@ -187,7 +217,7 @@ export class YXmlElement extends YXmlFragment {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlElement}
*
* @function

View File

@@ -1,7 +1,7 @@
import {
YEvent,
YXmlElement, YXmlFragment, Transaction // eslint-disable-line
YXmlText, YXmlElement, YXmlFragment, Transaction // eslint-disable-line
} from '../internals.js'
/**
@@ -9,7 +9,7 @@ import {
*/
export class YXmlEvent extends YEvent {
/**
* @param {YXmlElement|YXmlFragment} target The target on which the event is created.
* @param {YXmlElement|YXmlText|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
@@ -25,7 +25,7 @@ export class YXmlEvent extends YEvent {
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string|null>}
* @type {Set<string>}
*/
this.attributesChanged = new Set()
subs.forEach((sub) => {

View File

@@ -9,14 +9,19 @@ import {
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListInsertGenericsAfter,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
typeListGet,
typeListSlice,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
@@ -128,6 +133,14 @@ 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.
*
@@ -148,6 +161,16 @@ export class YXmlFragment extends AbstractType {
return new YXmlFragment()
}
/**
* @return {YXmlFragment}
*/
clone () {
const el = new YXmlFragment()
// @ts-ignore
el.insert(0, el.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
@@ -290,6 +313,32 @@ 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.
*
@@ -316,13 +365,52 @@ 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)
}
/**
* 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 {AbstractUpdateEncoder} encoder The encoder to write data to.
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlFragmentRefID)
@@ -330,7 +418,7 @@ export class YXmlFragment extends AbstractType {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlFragment}
*
* @private

View File

@@ -2,7 +2,7 @@
import {
YMap,
YXmlHookRefID,
AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line
} from '../internals.js'
/**
@@ -29,6 +29,17 @@ 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.
*
@@ -65,7 +76,7 @@ export class YXmlHook extends YMap {
*
* This is called when this Item is sent to a remote peer.
*
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlHookRefID)
@@ -74,7 +85,7 @@ export class YXmlHook extends YMap {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlHook}
*
* @private

View File

@@ -2,7 +2,7 @@
import {
YText,
YXmlTextRefID,
AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
ContentType, YXmlElement, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, // eslint-disable-line
} from '../internals.js'
/**
@@ -10,10 +10,35 @@ import {
* 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.
*
@@ -79,7 +104,7 @@ export class YXmlText extends YText {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YXmlTextRefID)
@@ -87,7 +112,7 @@ export class YXmlText extends YText {
}
/**
* @param {AbstractUpdateDecoder} decoder
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlText}
*
* @private

View File

@@ -4,7 +4,8 @@ import {
getState,
splitItem,
iterateStructs,
AbstractUpdateDecoder, AbstractDSDecoder, AbstractDSEncoder, DSDecoderV2, DSEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
UpdateEncoderV2,
DSDecoderV1, DSEncoderV1, DSDecoderV2, DSEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array.js'
@@ -121,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 += right.len
if (left.clock + left.len >= right.clock) {
left.len = math.max(left.len, right.clock + right.len - left.clock)
} else {
if (j < i) {
dels[j] = right
@@ -210,7 +211,7 @@ export const createDeleteSetFromStructStore = ss => {
}
/**
* @param {AbstractDSEncoder} encoder
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {DeleteSet} ds
*
* @private
@@ -232,7 +233,7 @@ export const writeDeleteSet = (encoder, ds) => {
}
/**
* @param {AbstractDSDecoder} decoder
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @return {DeleteSet}
*
* @private
@@ -260,9 +261,10 @@ export const readDeleteSet = decoder => {
*/
/**
* @param {AbstractDSDecoder} decoder
* @param {DSDecoderV1 | DSDecoderV2} 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
@@ -315,9 +317,10 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => {
}
}
if (unappliedDS.clients.size > 0) {
// TODO: no need for encoding+decoding ds anymore
const unappliedDSEncoder = new DSEncoderV2()
writeDeleteSet(unappliedDSEncoder, unappliedDS)
store.pendingDeleteReaders.push(new DSDecoderV2(decoding.createDecoder((unappliedDSEncoder.toUint8Array()))))
const ds = new UpdateEncoderV2()
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
writeDeleteSet(ds, unappliedDS)
return ds.toUint8Array()
}
return null
}

View File

@@ -10,30 +10,39 @@ import {
YMap,
YXmlFragment,
transact,
Item, Transaction, YEvent // eslint-disable-line
ContentDoc, Item, Transaction, YEvent // eslint-disable-line
} from '../internals.js'
import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js'
import * as array from 'lib0/array.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 {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.
*/
/**
* A Yjs instance handles the state of shared data.
* @extends Observable<string>
*/
export class Doc extends Observable {
/**
* @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.
* @param {DocOpts} [opts] configuration
*/
constructor ({ gc = true, gcFilter = () => true } = {}) {
constructor ({ guid = random.uuidv4(), gc = true, gcFilter = () => true, meta = null, autoLoad = false } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
this.guid = guid
/**
* @type {Map<string, AbstractType<YEvent>>}
*/
@@ -47,6 +56,43 @@ 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 = autoLoad
this.autoLoad = autoLoad
this.meta = meta
}
/**
* 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))
}
/**
@@ -171,6 +217,9 @@ export class Doc extends Observable {
/**
* 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>}
*/
@@ -191,13 +240,33 @@ export class Doc extends Observable {
* 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)
if (item.deleted) {
// @ts-ignore
content.doc = null
} else {
content.doc = new Doc({ guid: this.guid, ...content.opts })
content.doc._item = item
}
transact(/** @type {any} */ (item).parent.doc, transaction => {
if (!item.deleted) {
transaction.subdocsAdded.add(content.doc)
}
transaction.subdocsRemoved.add(this)
}, null, true)
}
this.emit('destroyed', [true])
this.emit('destroy', [this])
super.destroy()
}
/**
* @param {string} eventName
* @param {function} f
* @param {function(...any):any} f
*/
on (eventName, f) {
super.on(eventName, f)

View File

@@ -51,7 +51,12 @@ export const addEventHandlerListener = (eventHandler, f) =>
* @function
*/
export const removeEventHandlerListener = (eventHandler, f) => {
eventHandler.l = eventHandler.l.filter(g => f !== g)
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.')
}
}
/**

View File

@@ -51,7 +51,7 @@ export class PermanentUserData {
})
})
})
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(encodedDs)))))
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs))))))
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
)

View File

@@ -45,8 +45,9 @@ export class RelativePosition {
* @param {ID|null} type
* @param {string|null} tname
* @param {ID|null} item
* @param {number} assoc
*/
constructor (type, tname, item) {
constructor (type, tname, item, assoc = 0) {
/**
* @type {ID|null}
*/
@@ -59,23 +60,57 @@ 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))
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 class AbsolutePosition {
/**
* @param {AbstractType<any>} type
* @param {number} index
* @param {number} [assoc]
*/
constructor (type, index) {
constructor (type, index, assoc = 0) {
/**
* @type {AbstractType<any>}
*/
@@ -84,24 +119,27 @@ 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) => new AbsolutePosition(type, index)
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
/**
* @param {AbstractType<any>} type
* @param {ID|null} item
* @param {number} [assoc]
*
* @function
*/
export const createRelativePosition = (type, item) => {
export const createRelativePosition = (type, item, assoc) => {
let typeid = null
let tname = null
if (type._item === null) {
@@ -109,7 +147,7 @@ export const createRelativePosition = (type, item) => {
} else {
typeid = createID(type._item.id.client, type._item.id.clock)
}
return new RelativePosition(typeid, tname, item)
return new RelativePosition(typeid, tname, item, assoc)
}
/**
@@ -117,23 +155,35 @@ export const createRelativePosition = (type, item) => {
*
* @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) => {
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
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))
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
}
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)
return createRelativePosition(type, null, assoc)
}
/**
@@ -143,7 +193,7 @@ export const createRelativePositionFromTypeIndex = (type, index) => {
* @function
*/
export const writeRelativePosition = (encoder, rpos) => {
const { type, tname, item } = rpos
const { type, tname, item, assoc } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
writeID(encoder, item)
@@ -158,6 +208,7 @@ export const writeRelativePosition = (encoder, rpos) => {
} else {
throw error.unexpectedCase()
}
encoding.writeVarInt(encoder, assoc)
return encoder
}
@@ -173,7 +224,7 @@ export const encodeRelativePosition = rpos => {
/**
* @param {decoding.Decoder} decoder
* @return {RelativePosition|null}
* @return {RelativePosition}
*
* @function
*/
@@ -195,12 +246,13 @@ export const readRelativePosition = decoder => {
type = readID(decoder)
}
}
return new RelativePosition(type, tname, itemID)
const assoc = decoding.hasContent(decoder) ? decoding.readVarInt(decoder) : 0
return new RelativePosition(type, tname, itemID, assoc)
}
/**
* @param {Uint8Array} uint8Array
* @return {RelativePosition|null}
* @return {RelativePosition}
*/
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
@@ -216,6 +268,7 @@ 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) {
@@ -229,7 +282,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
index = (right.deleted || !right.countable) ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
@@ -256,17 +309,22 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
} else {
throw error.unexpectedCase()
}
if (assoc >= 0) {
index = type._length
} else {
index = 0
}
return createAbsolutePosition(type, index)
}
return createAbsolutePosition(type, index, rpos.assoc)
}
/**
* @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 !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc
)

View File

@@ -12,13 +12,16 @@ import {
createDeleteSet,
createID,
getState,
AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line
findIndexSS,
UpdateEncoderV2,
applyUpdateV2,
DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as decoding from 'lib0/decoding.js'
import { DefaultDSEncoder } from './encoding.js'
import * as encoding from 'lib0/encoding.js'
export class Snapshot {
/**
@@ -74,7 +77,7 @@ export const equalSnapshots = (snap1, snap2) => {
/**
* @param {Snapshot} snapshot
* @param {AbstractDSEncoder} [encoder]
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @return {Uint8Array}
*/
export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
@@ -87,11 +90,11 @@ export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DefaultDSEncoder())
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DSEncoderV1())
/**
* @param {Uint8Array} buf
* @param {AbstractDSDecoder} [decoder]
* @param {DSDecoderV1 | DSDecoderV2} [decoder]
* @return {Snapshot}
*/
export const decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(decoding.createDecoder(buf))) => {
@@ -148,3 +151,51 @@ 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

@@ -15,24 +15,13 @@ export class StructStore {
*/
this.clients = new Map()
/**
* 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>}>}
* @type {null | { missing: Map<number, number>, update: Uint8Array }}
*/
this.pendingClientsStructRefs = new Map()
this.pendingStructs = null
/**
* Stack of pending structs waiting for struct dependencies
* Maximum length of stack is structReaders.size
* @type {Array<GC|Item>}
* @type {null | Uint8Array}
*/
this.pendingStack = []
/**
* @type {Array<DSDecoderV2>}
*/
this.pendingDeleteReaders = []
this.pendingDs = null
}
}

View File

@@ -11,7 +11,7 @@ import {
Item,
generateNewClientId,
createID,
AbstractUpdateEncoder, GC, StructStore, UpdateEncoderV2, DefaultUpdateEncoder, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
@@ -102,11 +102,23 @@ 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 {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
* @return {boolean} Whether data was written.
*/
@@ -272,6 +284,9 @@ 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)
@@ -322,19 +337,25 @@ const cleanupTransactions = (transactionCleanups, i) => {
// @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 DefaultUpdateEncoder()
const encoder = new UpdateEncoderV1()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc])
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
}
}
if (doc._observers.has('updateV2')) {
const encoder = new UpdateEncoderV2()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc])
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
}
}
transaction.subdocsAdded.forEach(subdoc => doc.subdocs.add(subdoc))
transaction.subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
doc.emit('subdocs', [{ loaded: transaction.subdocsLoaded, added: transaction.subdocsAdded, removed: transaction.subdocsRemoved }])
transaction.subdocsRemoved.forEach(subdoc => subdoc.destroy())
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
doc.emit('afterAllTransactions', [doc, transactionCleanups])

View File

@@ -5,12 +5,12 @@ import {
transact,
createID,
redoItem,
iterateStructs,
isParentOf,
followRedone,
getItemCleanStart,
getState,
ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
isDeleted,
addToDeleteSet,
Transaction, Doc, Item, GC, DeleteSet, AbstractType, YEvent // eslint-disable-line
} from '../internals.js'
import * as time from 'lib0/time.js'
@@ -18,14 +18,12 @@ import { Observable } from 'lib0/observable.js'
class StackItem {
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} beforeState
* @param {Map<number,number>} afterState
* @param {DeleteSet} deletions
* @param {DeleteSet} insertions
*/
constructor (ds, beforeState, afterState) {
this.ds = ds
this.beforeState = beforeState
this.afterState = afterState
constructor (deletions, insertions) {
this.insertions = insertions
this.deletions = deletions
/**
* Use this to save and restore metadata like selection range
*/
@@ -45,6 +43,11 @@ 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 => {
@@ -60,28 +63,13 @@ const popStackItem = (undoManager, stack, eventType) => {
*/
const itemsToDelete = []
let performedChange = false
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 => {
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))
}
if (item.length > len) {
getItemCleanStart(transaction, createID(item.id.client, endClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
@@ -89,19 +77,12 @@ const popStackItem = (undoManager, stack, eventType) => {
}
}
})
}
})
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
iterateDeletedStructs(transaction, stackItem.deletions, struct => {
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// 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)
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
!isDeleted(stackItem.insertions, struct.id)
) {
itemsToRedo.add(struct)
}
@@ -119,11 +100,19 @@ const popStackItem = (undoManager, stack, eventType) => {
}
}
result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
transaction.changed.forEach((subProps, type) => {
// destroy search marker if necessary
if (subProps.has(null) && type._searchMarker) {
type._searchMarker.length = 0
}
})
_tr = transaction
}, undoManager)
if (result != null) {
const changedParentTypes = _tr.changedParentTypes
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType, changedParentTypes }, undoManager])
}
return result
}
@@ -151,10 +140,7 @@ 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, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
this.deleteFilter = deleteFilter
@@ -191,17 +177,23 @@ export class UndoManager extends Observable {
// neither undoing nor redoing: delete redoStack
this.redoStack = []
}
const beforeState = transaction.beforeState
const afterState = transaction.afterState
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 now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.afterState = afterState
lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet])
lastOp.insertions = mergeDeleteSets([lastOp.insertions, insertions])
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
stack.push(new StackItem(transaction.deleteSet, insertions))
}
if (!undoing && !redoing) {
this.lastChange = now
@@ -212,7 +204,7 @@ export class UndoManager extends Observable {
keepItem(item, true)
}
})
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this])
})
}
@@ -222,7 +214,7 @@ export class UndoManager extends Observable {
* @param {StackItem} stackItem
*/
const clearItem = stackItem => {
iterateDeletedStructs(transaction, stackItem.ds, item => {
iterateDeletedStructs(transaction, stackItem.deletions, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}

View File

@@ -1,129 +1,9 @@
import * as buffer from 'lib0/buffer.js'
import * as error from 'lib0/error.js'
import * as decoding from 'lib0/decoding.js'
import {
ID, createID
} from '../internals.js'
export class AbstractDSDecoder {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
this.restDecoder = decoder
error.methodUnimplemented()
}
resetDsCurVal () { }
/**
* @return {number}
*/
readDsClock () {
error.methodUnimplemented()
}
/**
* @return {number}
*/
readDsLen () {
error.methodUnimplemented()
}
}
export class AbstractUpdateDecoder extends AbstractDSDecoder {
/**
* @return {ID}
*/
readLeftID () {
error.methodUnimplemented()
}
/**
* @return {ID}
*/
readRightID () {
error.methodUnimplemented()
}
/**
* Read the next client id.
* Use this in favor of readID whenever possible to reduce the number of objects created.
*
* @return {number}
*/
readClient () {
error.methodUnimplemented()
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readInfo () {
error.methodUnimplemented()
}
/**
* @return {string}
*/
readString () {
error.methodUnimplemented()
}
/**
* @return {boolean} isKey
*/
readParentInfo () {
error.methodUnimplemented()
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readTypeRef () {
error.methodUnimplemented()
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @return {number} len
*/
readLen () {
error.methodUnimplemented()
}
/**
* @return {any}
*/
readAny () {
error.methodUnimplemented()
}
/**
* @return {Uint8Array}
*/
readBuf () {
error.methodUnimplemented()
}
/**
* Legacy implementation uses JSON parse. We use any-decoding in v2.
*
* @return {any}
*/
readJSON () {
error.methodUnimplemented()
}
/**
* @return {string}
*/
readKey () {
error.methodUnimplemented()
}
}
export class DSDecoderV1 {
/**
* @param {decoding.Decoder} decoder
@@ -247,6 +127,9 @@ export class DSDecoderV2 {
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
/**
* @private
*/
this.dsCurrVal = 0
this.restDecoder = decoder
}
@@ -255,11 +138,17 @@ export class DSDecoderV2 {
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
@@ -280,7 +169,7 @@ export class UpdateDecoderV2 extends DSDecoderV2 {
* @type {Array<string>}
*/
this.keys = []
decoding.readUint8(decoder) // read feature flag - currently unused
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))

View File

@@ -6,110 +6,9 @@ import {
ID // eslint-disable-line
} from '../internals.js'
export class AbstractDSEncoder {
constructor () {
this.restEncoder = encoding.createEncoder()
}
/**
* @return {Uint8Array}
*/
toUint8Array () {
error.methodUnimplemented()
}
/**
* Resets the ds value to 0.
* The v2 encoder uses this information to reset the initial diff value.
*/
resetDsCurVal () { }
/**
* @param {number} clock
*/
writeDsClock (clock) { }
/**
* @param {number} len
*/
writeDsLen (len) { }
}
export class AbstractUpdateEncoder extends AbstractDSEncoder {
/**
* @return {Uint8Array}
*/
toUint8Array () {
error.methodUnimplemented()
}
/**
* @param {ID} id
*/
writeLeftID (id) { }
/**
* @param {ID} id
*/
writeRightID (id) { }
/**
* Use writeClient and writeClock instead of writeID if possible.
* @param {number} client
*/
writeClient (client) { }
/**
* @param {number} info An unsigned 8-bit integer
*/
writeInfo (info) { }
/**
* @param {string} s
*/
writeString (s) { }
/**
* @param {boolean} isYKey
*/
writeParentInfo (isYKey) { }
/**
* @param {number} info An unsigned 8-bit integer
*/
writeTypeRef (info) { }
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @param {number} len
*/
writeLen (len) { }
/**
* @param {any} any
*/
writeAny (any) { }
/**
* @param {Uint8Array} buf
*/
writeBuf (buf) { }
/**
* @param {any} embed
*/
writeJSON (embed) { }
/**
* @param {string} key
*/
writeKey (key) { }
}
export class DSEncoderV1 {
constructor () {
this.restEncoder = new encoding.Encoder()
this.restEncoder = encoding.createEncoder()
}
toUint8Array () {
@@ -228,7 +127,7 @@ export class UpdateEncoderV1 extends DSEncoderV1 {
export class DSEncoderV2 {
constructor () {
this.restEncoder = new encoding.Encoder() // encodes all the rest / non-optimized
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
this.dsCurrVal = 0
}
@@ -288,7 +187,7 @@ export class UpdateEncoderV2 extends DSEncoderV2 {
toUint8Array () {
const encoder = encoding.createEncoder()
encoding.writeUint8(encoder, 0) // this is a feature flag that we might use in the future
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())

View File

@@ -40,6 +40,8 @@ export class YEvent {
/**
* 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
@@ -78,7 +80,7 @@ export class YEvent {
}
/**
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
* @return {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
*/
get changes () {
let changes = this._changes

View File

@@ -29,39 +29,23 @@ import {
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
DSDecoderV2,
DSEncoderV2,
DSDecoderV1,
DSEncoderV1,
AbstractDSEncoder, AbstractDSDecoder, AbstractUpdateEncoder, AbstractUpdateDecoder, AbstractContent, Doc, Transaction, GC, Item, StructStore, ID // eslint-disable-line
mergeUpdatesV2,
Skip,
diffUpdateV2,
DSDecoderV2, Doc, Transaction, GC, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as binary from 'lib0/binary.js'
import * as map from 'lib0/map.js'
export let DefaultDSEncoder = DSEncoderV1
export let DefaultDSDecoder = DSDecoderV1
export let DefaultUpdateEncoder = UpdateEncoderV1
export let DefaultUpdateDecoder = UpdateDecoderV1
export const useV1Encoding = () => {
DefaultDSEncoder = DSEncoderV1
DefaultDSDecoder = DSDecoderV1
DefaultUpdateEncoder = UpdateEncoderV1
DefaultUpdateDecoder = UpdateDecoderV1
}
export const useV2Encoding = () => {
DefaultDSEncoder = DSEncoderV2
DefaultDSDecoder = DSDecoderV2
DefaultUpdateEncoder = UpdateEncoderV2
DefaultUpdateDecoder = UpdateDecoderV2
}
import * as math from 'lib0/math.js'
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Array<GC|Item>} structs All structs by `client`
* @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)`
@@ -70,6 +54,7 @@ export const useV2Encoding = () => {
*/
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)
@@ -84,7 +69,7 @@ const writeStructs = (encoder, structs, client, clock) => {
}
/**
* @param {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {StructStore} store
* @param {Map<number,number>} _sm
*
@@ -116,15 +101,18 @@ export const writeClientsStructs = (encoder, store, _sm) => {
}
/**
* @param {AbstractUpdateDecoder} decoder The decoder object to read data from.
* @param {Map<number,Array<GC|Item>>} clientRefs
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
* @param {Doc} doc
* @return {Map<number,Array<GC|Item>>}
* @return {Map<number, { i: number, refs: Array<Item | GC> }>}
*
* @private
* @function
*/
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
export const readClientsStructRefs = (decoder, doc) => {
/**
* @type {Map<number, { i: number, refs: Array<Item | GC> }>}
*/
const clientRefs = map.create()
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
@@ -135,10 +123,24 @@ export const readClientsStructRefs = (decoder, clientRefs, doc) => {
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
// const start = performance.now()
clientRefs.set(client, refs)
clientRefs.set(client, { i: 0, refs })
for (let i = 0; i < numberOfStructs; i++) {
const info = decoder.readInfo()
if ((binary.BITS5 & info) !== 0) {
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
@@ -186,10 +188,7 @@ export const readClientsStructRefs = (decoder, clientRefs, doc) => {
*/
refs[i] = struct
clock += struct.length
} else {
const len = decoder.readLen()
refs[i] = new GC(createID(client, clock), len)
clock += len
}
}
}
// console.log('time to read: ', performance.now() - start) // @todo remove
@@ -218,26 +217,32 @@ export const readClientsStructRefs = (decoder, clientRefs, 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 resumeStructIntegration = (transaction, store) => {
const stack = store.pendingStack // @todo don't forget to append stackhead at the end
const clientsStructRefs = store.pendingClientsStructRefs
const integrateStructs = (transaction, store, clientsStructRefs) => {
/**
* @type {Array<Item | GC>}
*/
const stack = []
// 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.
const clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
let clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
if (clientsStructRefsIds.length === 0) {
return
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 {
store.pendingClientsStructRefs.clear()
return null
}
}
@@ -245,49 +250,87 @@ const resumeStructIntegration = (transaction, store) => {
}
let curStructsTarget = getNextStructTarget()
if (curStructsTarget === null && stack.length === 0) {
return
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 = stack.length > 0
? /** @type {GC|Item} */ (stack.pop())
: /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
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
}
// 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 = stackHead.id.clock < localClock ? localClock - stackHead.id.clock : 0
if (stackHead.id.clock + 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 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()
} 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(stackHead.id.client) || { refs: [], i: 0 }
if (structRefs.refs.length !== structRefs.i) {
const r = structRefs.refs[structRefs.i]
if (r.id.clock < stackHead.id.clock) {
// put ref with smaller clock on stack instead and continue
structRefs.refs[structRefs.i] = stackHead
stackHead = 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
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
}
}
// wait until missing struct is available
stack.push(stackHead)
return
}
const missing = stackHead.getMissing(transaction, store)
if (missing === null) {
if (offset === 0 || offset < stackHead.length) {
} 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)
}
}
}
// iterate to next stackHead
if (stack.length > 0) {
stackHead = /** @type {GC|Item} */ (stack.pop())
@@ -302,41 +345,20 @@ const resumeStructIntegration = (transaction, store) => {
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
}
}
} else {
// get the struct reader that has the missing struct
/**
* @type {{ refs: Array<GC|Item>, i: number }}
*/
const structRefs = clientsStructRefs.get(missing) || { refs: [], i: 0 }
if (structRefs.refs.length === structRefs.i) {
// This update message causally depends on another update message.
stack.push(stackHead)
return
}
stack.push(stackHead)
stackHead = structRefs.refs[structRefs.i++]
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() }
}
}
store.pendingClientsStructRefs.clear()
return null
}
/**
* @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 {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
*
* @private
@@ -344,78 +366,6 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
*/
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
/**
* @param {StructStore} store
* @param {Map<number, Array<GC|Item>>} clientsStructsRefs
*
* @private
* @function
*/
const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
const pendingClientsStructRefs = store.pendingClientsStructRefs
clientsStructsRefs.forEach((structRefs, client) => {
const pendingStructRefs = pendingClientsStructRefs.get(client)
if (pendingStructRefs === undefined) {
pendingClientsStructRefs.set(client, { refs: structRefs, i: 0 })
} else {
// 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])
}
pendingStructRefs.i = 0
pendingStructRefs.refs = merged.sort((r1, r2) => r1.id.clock - r2.id.clock)
}
})
}
/**
* @param {Map<number,{refs:Array<GC|Item>,i:number}>} pendingClientsStructRefs
*/
const cleanupPendingStructs = pendingClientsStructRefs => {
// cleanup pendingClientsStructs if not fully finished
pendingClientsStructRefs.forEach((refs, client) => {
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 {AbstractUpdateDecoder} 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()
// let start = performance.now()
readClientsStructRefs(decoder, clientsStructRefs, transaction.doc)
// console.log('time to read structs: ', performance.now() - start) // @todo remove
// start = performance.now()
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
// console.log('time to merge: ', performance.now() - start) // @todo remove
// start = performance.now()
resumeStructIntegration(transaction, store)
// console.log('time to integrate: ', performance.now() - start) // @todo remove
// start = performance.now()
cleanupPendingStructs(store.pendingClientsStructRefs)
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
// start = performance.now()
tryResumePendingDeleteReaders(transaction, store)
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
// start = performance.now()
}
/**
* Read and apply a document update.
*
@@ -424,14 +374,75 @@ export const readStructs = (decoder, transaction, store) => {
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* @param {AbstractUpdateDecoder} [structDecoder]
* @param {UpdateDecoderV1 | UpdateDecoderV2} [structDecoder]
*
* @function
*/
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
transact(ydoc, transaction => {
readStructs(structDecoder, transaction, ydoc.store)
readAndApplyDeleteSet(structDecoder, transaction, ydoc.store)
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])
}
} 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
}
} else {
// Either dsRest == null && pendingDs == null OR dsRest != null
store.pendingDs = dsRest
}
// 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)
/**
@@ -445,7 +456,7 @@ export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = n
*
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new DefaultUpdateDecoder(decoder))
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()`.
@@ -475,13 +486,13 @@ export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = Update
*
* @function
*/
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, DefaultUpdateDecoder)
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1)
/**
* 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 {AbstractUpdateEncoder} encoder
* @param {UpdateEncoderV1 | UpdateEncoderV2} 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
*
@@ -500,15 +511,29 @@ 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 {AbstractUpdateEncoder} [encoder]
* @param {UpdateEncoderV1 | UpdateEncoderV2} [encoder]
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector, encoder = new UpdateEncoderV2()) => {
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
const targetStateVector = decodeStateVector(encodedTargetStateVector)
writeStateAsUpdate(encoder, doc, targetStateVector)
return encoder.toUint8Array()
const updates = [encoder.toUint8Array()]
// also add the pending updates (if there are any)
// @todo support diffirent encoders
if (encoder.constructor === UpdateEncoderV2) {
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) {
return mergeUpdatesV2(updates)
}
}
return updates[0]
}
/**
@@ -523,12 +548,12 @@ export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector, encoder = n
*
* @function
*/
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new DefaultUpdateEncoder())
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
/**
* Read state vector from Decoder and return as Map
*
* @param {AbstractDSDecoder} decoder
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
@@ -552,7 +577,7 @@ export const readStateVector = decoder => {
*
* @function
*/
export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
// export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
/**
* Read decodedState and return State as Map.
@@ -562,10 +587,10 @@ export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoder
*
* @function
*/
export const decodeStateVector = decodedState => readStateVector(new DefaultDSDecoder(decoding.createDecoder(decodedState)))
export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1(decoding.createDecoder(decodedState)))
/**
* @param {AbstractDSEncoder} encoder
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {Map<number,number>} sv
* @function
*/
@@ -579,7 +604,7 @@ export const writeStateVector = (encoder, sv) => {
}
/**
* @param {AbstractDSEncoder} encoder
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {Doc} doc
*
* @function
@@ -589,23 +614,27 @@ export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encod
/**
* Encode State as Uint8Array.
*
* @param {Doc} doc
* @param {AbstractDSEncoder} [encoder]
* @param {Doc|Map<number,number>} doc
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVectorV2 = (doc, encoder = new DSEncoderV2()) => {
if (doc instanceof Map) {
writeStateVector(encoder, doc)
} else {
writeDocumentStateVector(encoder, doc)
}
return encoder.toUint8Array()
}
/**
* Encode State as Uint8Array.
*
* @param {Doc} doc
* @param {Doc|Map<number,number>} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DefaultDSEncoder())
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DSEncoderV1())

510
src/utils/updates.js Normal file
View File

@@ -0,0 +1,510 @@
import * as binary from 'lib0/binary.js'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import * as logging from 'lib0/logging.js'
import * as math from 'lib0/math.js'
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)
}
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)), true)
let curr = updateDecoder.curr
if (curr !== null) {
let size = 1
let currClient = curr.id.client
let currClock = curr.id.clock
let stopCounting = false
for (; curr !== null; curr = updateDecoder.next()) {
if (currClient !== curr.id.client) {
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
stopCounting = false
}
if (curr.constructor === Skip) {
stopCounting = true
}
if (!stopCounting) {
currClock = curr.id.clock + curr.length
}
}
// write what we have
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) => {
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) {
return dec1.curr.constructor === dec2.curr.constructor ? 0 : (
dec1.curr.constructor === Skip ? 1 : -1
)
} 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)
// 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()
}
if (curr === null || curr.id.client !== firstClient) {
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)
}
}

View File

@@ -57,3 +57,70 @@ export const testToJSON = tc => {
}
}, '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' }))
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'])
}
}

View File

@@ -1,4 +1,5 @@
import * as t from 'lib0/testing.js'
import * as promise from 'lib0/promise.js'
import {
contentRefs,
@@ -9,14 +10,19 @@ import {
readContentEmbed,
readContentType,
readContentFormat,
readContentAny
readContentAny,
readContentDoc,
Doc,
PermanentUserData,
encodeStateAsUpdate,
applyUpdate
} from '../src/internals.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 9)
t.assert(contentRefs.length === 11)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
@@ -25,4 +31,34 @@ 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')
}

View File

@@ -7,6 +7,9 @@ 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 { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js'
@@ -16,7 +19,7 @@ if (isBrowser) {
log.createVConsole(document.body)
}
runTests({
doc, map, array, text, xml, encoding, undoredo, compatibility
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions
}).then(success => {
/* istanbul ignore next */
if (isNode) {

View File

@@ -0,0 +1,104 @@
import * as Y from '../src/internals'
import * as t from 'lib0/testing.js'
/**
* @param {Y.YText} 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)
}

171
tests/snapshot.tests.js Normal file
View File

@@ -0,0 +1,171 @@
import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals'
import * as t from 'lib0/testing.js'
import { init } from './testHelper'
/**
* @param {t.TestCase} tc
*/
export const testBasicRestoreSnapshot = tc => {
const doc = new Doc({ gc: false })
doc.getArray('array').insert(0, ['hello'])
const snap = snapshot(doc)
doc.getArray('array').insert(1, ['world'])
const docRestored = 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 Doc({ gc: false })
const snap = snapshot(doc)
snap.sv.set(9999, 0)
doc.getArray().insert(0, ['world'])
const docRestored = 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 = snapshot(doc)
const docRestored2 = createDocFromSnapshot(doc, snap2)
t.compare(docRestored2.getArray().toArray(), ['world'])
}
/**
* @param {t.TestCase} tc
*/
export const testRestoreSnapshotWithSubType = tc => {
const doc = new Doc({ gc: false })
doc.getArray('array').insert(0, [new YMap()])
const subMap = doc.getArray('array').get(0)
subMap.set('key1', 'value1')
const snap = snapshot(doc)
subMap.set('key2', 'value2')
const docRestored = 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 Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2'])
const snap = snapshot(doc)
doc.getArray('array').delete(0)
const docRestored = 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 Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getMap('map').set('test', 1)
doc.getArray('array').insert(0, ['item0'])
const snap = snapshot(doc)
doc.getArray('array').delete(1)
const docRestored = 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 Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getArray('array').delete(0)
const snap = snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = 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 Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
doc.getArray('array').delete(1)
const snap = snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = 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 Doc
*/
const doc0 = array0.doc
/**
* @type 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 = snapshot(array0.doc)
array0.insert(2, ['user1item2'])
testConnector.syncAll()
array1.insert(3, ['user2item2'])
testConnector.syncAll()
const docRestored0 = createDocFromSnapshot(array0.doc, snap)
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
const docRestored1 = createDocFromSnapshot(array1.doc, snap)
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
}

View File

@@ -27,6 +27,39 @@ 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 {
/**
* @param {TestConnector} testConnector
@@ -44,12 +77,19 @@ 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('update', /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
this.on(enc.updateEventName, /** @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()
@@ -162,6 +202,17 @@ export class TestConnector {
// send reply message
sender._receive(encoding.toUint8Array(encoder), receiver)
}
{
// If update message, add the received message to the list of received messages
const decoder = decoding.createDecoder(m)
const messageType = decoding.readVarUint(decoder)
switch (messageType) {
case syncProtocol.messageYjsUpdate:
case syncProtocol.messageYjsSyncStep2:
receiver.updates.push(decoding.readVarUint8Array(decoder))
break
}
}
return true
}
return false
@@ -240,9 +291,9 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
const gen = tc.prng
// choose an encoding approach at random
if (prng.bool(gen)) {
Y.useV2Encoding()
useV2Encoding()
} else {
Y.useV1Encoding()
useV1Encoding()
}
const testConnector = new TestConnector(gen)
@@ -258,7 +309,7 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
}
testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
Y.useV1Encoding()
useV1Encoding()
return /** @type {any} */ (result)
}
@@ -274,14 +325,21 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
export const compare = users => {
users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {}
// 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))
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.YXmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta())
for (const u of users) {
t.assert(u.store.pendingDeleteReaders.length === 0)
t.assert(u.store.pendingStack.length === 0)
t.assert(u.store.pendingClientsStructRefs.size === 0)
t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null)
}
// Test Array iterator
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))

View File

@@ -53,6 +53,28 @@ 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
*/
@@ -179,10 +201,12 @@ export const testUndoEvents = tc => {
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')

246
tests/updates.tests.js Normal file
View File

@@ -0,0 +1,246 @@
import * as t from 'lib0/testing.js'
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.js'
import * as decoding from 'lib0/decoding.js'
/**
* @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 {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)
const decDiffedSV = Y.decodeStateVector(enc.encodeStateVectorFromUpdate(diffed))
t.compare(partMeta, diffedMeta)
t.compare(decDiffedSV, partMeta.to)
{
// 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)
})
}
/**
* @todo be able to apply Skip structs to Yjs docs
*/

View File

@@ -17,6 +17,21 @@ export const testBasicUpdate = tc => {
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])
}
/**
* @param {t.TestCase} tc
*/
@@ -49,7 +64,7 @@ export const testInsertThreeElementsTryRegetProperty = tc => {
* @param {t.TestCase} tc
*/
export const testConcurrentInsertWithThreeConflicts = tc => {
var { users, array0, array1, array2 } = init(tc, { users: 3 })
const { users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, [0])
array1.insert(0, [1])
array2.insert(0, [2])
@@ -92,7 +107,7 @@ export const testInsertionsInLateSync = tc => {
* @param {t.TestCase} tc
*/
export const testDisconnectReallyPreventsSendingMessages = tc => {
var { testConnector, users, array0, array1 } = init(tc, { users: 3 })
const { testConnector, users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
@@ -204,6 +219,34 @@ 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
*/
@@ -345,13 +388,13 @@ const getUniqueNumber = () => _uniqueNumber++
const arrayTransactions = [
function insert (user, gen) {
const yarray = user.getArray('array')
var uniqueNumber = getUniqueNumber()
var content = []
var len = prng.int32(gen, 1, 4)
for (var i = 0; i < len; i++) {
const uniqueNumber = getUniqueNumber()
const content = []
const len = prng.int32(gen, 1, 4)
for (let i = 0; i < len; i++) {
content.push(uniqueNumber)
}
var pos = prng.int32(gen, 0, yarray.length)
const pos = prng.int32(gen, 0, yarray.length)
const oldContent = yarray.toArray()
yarray.insert(pos, content)
oldContent.splice(pos, 0, ...content)
@@ -359,28 +402,28 @@ const arrayTransactions = [
},
function insertTypeArray (user, gen) {
const yarray = user.getArray('array')
var pos = prng.int32(gen, 0, yarray.length)
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Array()])
var array2 = yarray.get(pos)
const array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (user, gen) {
const yarray = user.getArray('array')
var pos = prng.int32(gen, 0, yarray.length)
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Map()])
var map = yarray.get(pos)
const map = yarray.get(pos)
map.set('someprop', 42)
map.set('someprop', 43)
map.set('someprop', 44)
},
function _delete (user, gen) {
const yarray = user.getArray('array')
var length = yarray.length
const length = yarray.length
if (length > 0) {
var somePos = prng.int32(gen, 0, length - 1)
var delLength = prng.int32(gen, 1, math.min(2, length - somePos))
let somePos = prng.int32(gen, 0, length - 1)
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
var type = yarray.get(somePos)
const type = yarray.get(somePos)
if (type.length > 0) {
somePos = prng.int32(gen, 0, type.length - 1)
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))

View File

@@ -138,7 +138,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
}
compare(users)
@@ -153,7 +153,7 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
const u = user.getMap('map')
t.compare(u.get('stuff'), 'c1')
}
compare(users)
@@ -183,7 +183,7 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
map1.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
@@ -200,7 +200,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
const u = user.getMap('map')
t.compare(u.get('stuff'), 'c3')
}
compare(users)
@@ -223,7 +223,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
map3.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
@@ -296,7 +296,7 @@ export const testObserversUsingObservedeep = tc => {
* @param {Object<string,any>} should
*/
const compareEvent = (is, should) => {
for (var key in should) {
for (const key in should) {
t.compare(should[key], is[key])
}
}
@@ -474,12 +474,12 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
const mapTransactions = [
function set (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
var value = prng.utf16String(gen)
const value = prng.utf16String(gen)
user.getMap('map').set(key, value)
},
function setType (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
const 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

@@ -78,6 +78,49 @@ 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
*/
@@ -249,6 +292,8 @@ export const testAppendChars = tc => {
t.assert(text0.length === N)
}
const largeDocumentSize = 100000
const id = Y.createID(0, 0)
const c = new Y.ContentString('a')
@@ -256,7 +301,7 @@ const c = new Y.ContentString('a')
* @param {t.TestCase} tc
*/
export const testBestCase = tc => {
const N = 2000000
const N = largeDocumentSize
const items = new Array(N)
t.measureTime('time to create two million items in the best case', () => {
const parent = /** @type {any} */ ({})
@@ -284,7 +329,9 @@ export const testBestCase = tc => {
}
const tryGc = () => {
// @ts-ignore
if (typeof global !== 'undefined' && global.gc) {
// @ts-ignore
global.gc()
}
}
@@ -293,7 +340,7 @@ const tryGc = () => {
* @param {t.TestCase} tc
*/
export const testLargeFragmentedDocument = tc => {
const itemsToInsert = 1000000
const itemsToInsert = largeDocumentSize
let update = /** @type {any} */ (null)
;(() => {
const doc1 = new Y.Doc()
@@ -321,6 +368,76 @@ export const testLargeFragmentedDocument = tc => {
})()
}
/**
* @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)
}
}
// RANDOM TESTS
let charCounter = 0

View File

@@ -73,3 +73,63 @@ 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()])
})
}