Compare commits
252 Commits
v13.0.0-99
...
v13.4.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a59118b5 | ||
|
|
bee397f1e5 | ||
|
|
1e97cf8323 | ||
|
|
c28ad0608e | ||
|
|
e19f16f22c | ||
|
|
6f074a873d | ||
|
|
4af04d6a29 | ||
|
|
97d9714710 | ||
|
|
ca667be68b | ||
|
|
8086a4f816 | ||
|
|
186f7140b6 | ||
|
|
edc1f9418f | ||
|
|
32b734b24d | ||
|
|
656328631c | ||
|
|
728bb6f1b2 | ||
|
|
fd59696b9a | ||
|
|
bfacd2e63a | ||
|
|
6bc9c220b9 | ||
|
|
7c0b98bbb2 | ||
|
|
034463798d | ||
|
|
bbc688975d | ||
|
|
ab9373c188 | ||
|
|
50e5964fcb | ||
|
|
31dee48f63 | ||
|
|
3404d22d12 | ||
|
|
d3b56702ad | ||
|
|
d5e6c26420 | ||
|
|
e497f07f7a | ||
|
|
510354d99f | ||
|
|
c3342d0b34 | ||
|
|
45af21f31e | ||
|
|
972d15dda5 | ||
|
|
fdf2063943 | ||
|
|
e81267d4df | ||
|
|
563c34f81a | ||
|
|
ba713983e3 | ||
|
|
bf2ee3680b | ||
|
|
b812a3dd6c | ||
|
|
b3f5b50377 | ||
|
|
7bcd4a828d | ||
|
|
cb705922b4 | ||
|
|
1ed58909d3 | ||
|
|
0aca7bbefa | ||
|
|
e1f0324840 | ||
|
|
7bac783490 | ||
|
|
1508c44f68 | ||
|
|
3dd843372f | ||
|
|
d6be4d9391 | ||
|
|
53f2344017 | ||
|
|
86f7631d1e | ||
|
|
3bb107504f | ||
|
|
4c46ebfb45 | ||
|
|
9d0d63ead7 | ||
|
|
39803c1d11 | ||
|
|
46fae57036 | ||
|
|
e9cb07da55 | ||
|
|
114f28f48e | ||
|
|
a1da486c8a | ||
|
|
4fb9cc2a30 | ||
|
|
e2c9eb7f01 | ||
|
|
6fd33c0720 | ||
|
|
72f3ce75b2 | ||
|
|
fd211731cc | ||
|
|
8049776074 | ||
|
|
32b1338d48 | ||
|
|
c2f0ca3fae | ||
|
|
dfc6b879de | ||
|
|
81f16ff0b5 | ||
|
|
e1a2ccd7f6 | ||
|
|
be8cc8a20c | ||
|
|
a253cfc090 | ||
|
|
992c0b5e32 | ||
|
|
e17d661769 | ||
|
|
fef3fc2a4a | ||
|
|
eee695eeeb | ||
|
|
38e38a92dc | ||
|
|
dadc08597d | ||
|
|
e769a2a354 | ||
|
|
0dd0a4be14 | ||
|
|
7193ae63b7 | ||
|
|
4d48224518 | ||
|
|
b4fc073aa5 | ||
|
|
9c0d1eb209 | ||
|
|
6a9f853d12 | ||
|
|
ce3b0f3043 | ||
|
|
94646b2f45 | ||
|
|
29c2ad4492 | ||
|
|
637fadf38e | ||
|
|
0c6c11d583 | ||
|
|
6f9a2c9df7 | ||
|
|
7876a96163 | ||
|
|
ceba4b1837 | ||
|
|
22653c799c | ||
|
|
68109b033f | ||
|
|
38eb2e502c | ||
|
|
270a69fcf6 | ||
|
|
6e3b708599 | ||
|
|
6e8167fe51 | ||
|
|
3449687280 | ||
|
|
3406247a3e | ||
|
|
076d550dfa | ||
|
|
bb45816f05 | ||
|
|
5414ac7f6e | ||
|
|
0b8f032364 | ||
|
|
dc136ff56a | ||
|
|
b73a720fdc | ||
|
|
cf420d6241 | ||
|
|
859e169c91 | ||
|
|
6c2cf0f769 | ||
|
|
1a942aa4e0 | ||
|
|
368dc6b36a | ||
|
|
2151c514e5 | ||
|
|
bb25ce7731 | ||
|
|
e31e968f0d | ||
|
|
1a494761a3 | ||
|
|
b434501d11 | ||
|
|
d1d86277b8 | ||
|
|
d7a11ccf4d | ||
|
|
4c48116947 | ||
|
|
6dd26d3b48 | ||
|
|
6b0154f046 | ||
|
|
7fb63de8fc | ||
|
|
c4d80d133d | ||
|
|
cebe96c001 | ||
|
|
4d2369ce21 | ||
|
|
5293ab4df1 | ||
|
|
e53c01c6c5 | ||
|
|
03faa27787 | ||
|
|
868dd5f0a5 | ||
|
|
fa58ce53cd | ||
|
|
0a0098fdfb | ||
|
|
a5a48d07f6 | ||
|
|
7b16d5c92d | ||
|
|
ee147c14f1 | ||
|
|
e86d5ba25b | ||
|
|
149ca6f636 | ||
|
|
e4223760b0 | ||
|
|
9d3dd4e082 | ||
|
|
5a4ff33bf4 | ||
|
|
a059fa12e9 | ||
|
|
0628d8f1c9 | ||
|
|
19e2d51190 | ||
|
|
60fab42b3f | ||
|
|
469404c6e1 | ||
|
|
c9756e5b57 | ||
|
|
601d24e930 | ||
|
|
b2c16674f2 | ||
|
|
13da804b5e | ||
|
|
c5ca7b6f8c | ||
|
|
f4b68c0dd4 | ||
|
|
4407f70052 | ||
|
|
8bb52a485a | ||
|
|
9fc18d5ce0 | ||
|
|
ada4f400b5 | ||
|
|
06048b87ee | ||
|
|
05dde1db01 | ||
|
|
b5b32c5b3c | ||
|
|
3f0e2078de | ||
|
|
21470bb409 | ||
|
|
772bb87d5c | ||
|
|
dab172fa1d | ||
|
|
a70c5112cd | ||
|
|
7cb423c046 | ||
|
|
4547b35641 | ||
|
|
4c87f9a021 | ||
|
|
4b08c67e06 | ||
|
|
9f5bc9ddfe | ||
|
|
8221db795a | ||
|
|
68b4418956 | ||
|
|
fa09ebfd82 | ||
|
|
b399ffa765 | ||
|
|
180f4667c1 | ||
|
|
9455373611 | ||
|
|
aa804d89c0 | ||
|
|
3ef51a5d1a | ||
|
|
e61089c659 | ||
|
|
97625cf29b | ||
|
|
a5dc6c27aa | ||
|
|
26a51bafc9 | ||
|
|
f40e09d156 | ||
|
|
81650bc8f6 | ||
|
|
c87caafeb6 | ||
|
|
195b26d90f | ||
|
|
7e0189ca84 | ||
|
|
192706f2a8 | ||
|
|
a4ce8ae07d | ||
|
|
e04a980af1 | ||
|
|
47d40eb6b0 | ||
|
|
fc4a39cc7d | ||
|
|
44e1fd9f14 | ||
|
|
02cc5a215f | ||
|
|
d1e8d50c43 | ||
|
|
18bb2d0719 | ||
|
|
45df311dd7 | ||
|
|
62888b4004 | ||
|
|
76c389dba0 | ||
|
|
78fa98c000 | ||
|
|
e9f9e08450 | ||
|
|
e3c59b0aa7 | ||
|
|
705dce7838 | ||
|
|
0fb55981ba | ||
|
|
89378e29ae | ||
|
|
cce35270ec | ||
|
|
d78180bf97 | ||
|
|
0ab415de3e | ||
|
|
ff3969caeb | ||
|
|
c82cc9f8d6 | ||
|
|
ef5c71bd8b | ||
|
|
bd6be3d23b | ||
|
|
0e6deab9c9 | ||
|
|
6cd9e2be32 | ||
|
|
ac8dab1e88 | ||
|
|
38ed725c2c | ||
|
|
a210bad25e | ||
|
|
6929a4f0f8 | ||
|
|
52dacfa5f2 | ||
|
|
27efe86f9c | ||
|
|
882b9055c7 | ||
|
|
e089089413 | ||
|
|
197932752e | ||
|
|
f0b2bdaf34 | ||
|
|
b96362c0f1 | ||
|
|
67f241cd7a | ||
|
|
c8af0bebf7 | ||
|
|
4f35e799a6 | ||
|
|
eb2a52dd26 | ||
|
|
189b1068ae | ||
|
|
7a3b60a5d7 | ||
|
|
99f06fc093 | ||
|
|
22917bca19 | ||
|
|
7f0e25dcba | ||
|
|
d90c9b1cb2 | ||
|
|
c426055f17 | ||
|
|
18c9010b63 | ||
|
|
c3edac62ef | ||
|
|
755de18fd5 | ||
|
|
641dc25076 | ||
|
|
1d58ea785f | ||
|
|
f53dff5043 | ||
|
|
74d1a31f49 | ||
|
|
d1063ab70b | ||
|
|
f4c919d9ec | ||
|
|
aeb23dbaa9 | ||
|
|
6d4f0c0cdd | ||
|
|
303138f309 | ||
|
|
ad373a3dce | ||
|
|
2150fa58f2 | ||
|
|
ece4841b5c | ||
|
|
8103220c05 | ||
|
|
66d500f08d | ||
|
|
5f8e7c7ba7 | ||
|
|
7b8eee6b25 |
7
.circleci/config.yml
Normal file
7
.circleci/config.yml
Normal 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
29
.github/workflows/node.js.yml
vendored
Normal 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
|
||||
31
.github/workflows/nodejs.yml
vendored
Normal file
31
.github/workflows/nodejs.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 13.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 lint
|
||||
- run: npm run test-extensive
|
||||
env:
|
||||
CI: true
|
||||
12
.jsdoc.json
12
.jsdoc.json
@@ -17,10 +17,13 @@
|
||||
"useCollapsibles": true,
|
||||
"collapse": true,
|
||||
"resources": {
|
||||
"yjs.dev": "Yjs website"
|
||||
"yjs.dev": "Website",
|
||||
"docs.yjs.dev": "Docs",
|
||||
"discuss.yjs.dev": "Forum",
|
||||
"https://gitter.im/Yjs/community": "Chat"
|
||||
},
|
||||
"logo": {
|
||||
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
|
||||
"url": "https://yjs.dev/images/logo/yjs-512x512.png",
|
||||
"width": "162px",
|
||||
"height": "162px",
|
||||
"link": "/"
|
||||
@@ -35,7 +38,7 @@
|
||||
],
|
||||
"default": {
|
||||
"staticFiles": {
|
||||
"include": ["examples/"]
|
||||
"include": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,7 +47,6 @@
|
||||
"encoding": "utf8",
|
||||
"private": false,
|
||||
"recurse": true,
|
||||
"template": "./node_modules/tui-jsdoc-template",
|
||||
"tutorials": "./examples"
|
||||
"template": "./node_modules/tui-jsdoc-template"
|
||||
}
|
||||
}
|
||||
|
||||
179
INTERNALS.md
Normal file
179
INTERNALS.md
Normal 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.
|
||||
273
README.md
273
README.md
@@ -15,11 +15,58 @@ suited for even large documents.
|
||||
|
||||
* Demos: [https://github.com/yjs/yjs-demos](https://github.com/yjs/yjs-demos)
|
||||
* Discuss: [https://discuss.yjs.dev](https://discuss.yjs.dev)
|
||||
* Benchmarks:
|
||||
* Benchmark Yjs vs. Automerge:
|
||||
[https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
|
||||
* Podcast [**"Yjs Deep Dive into real time collaborative editing solutions":**](https://www.tag1consulting.com/blog/deep-dive-real-time-collaborative-editing-solutions-tagteamtalk-001-0)
|
||||
* Podcast [**"Google Docs-style editing in Gutenberg with the YJS framework":**](https://publishpress.com/blog/yjs/)
|
||||
|
||||
:warning: This is the documentation for v13 (still in alpha). For the stable v12
|
||||
release checkout the [v12 docs](./README.v12.md) :warning:
|
||||
:construction_worker_woman: If you are looking for professional (paid) support to
|
||||
build collaborative or distributed applications ping us at
|
||||
<yjs@tag1consulting.com>. Otherwise you can find help on our
|
||||
[discussion board](https://discuss.yjs.dev).
|
||||
|
||||
## Sponsors
|
||||
|
||||
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:
|
||||
|
||||
[](https://github.com/davidhq)
|
||||
[](https://github.com/ifiokjr)
|
||||
[](https://github.com/burke)
|
||||
[](https://github.com/cben)
|
||||
[](https://github.com/tommoor)
|
||||
[](https://github.com/michaelemeyers)
|
||||
[](https://github.com/csbenjamin)
|
||||
[](https://github.com/AdventureBeard)
|
||||
[](https://github.com/nimbuswebinc)
|
||||
[](https://github.com/journeyapps)
|
||||
[](https://github.com/adabru)
|
||||
[](https://github.com/NathanaelA)
|
||||
[<img src="https://room.sh/img/icons/android-chrome-192x192.png" height="60px" />](https://room.sh/)
|
||||
|
||||
Sponsorship also comes with special perks! [](https://github.com/sponsors/dmonad)
|
||||
|
||||
## Who is using Yjs
|
||||
|
||||
* [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:
|
||||
* [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:
|
||||
* [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by
|
||||
Nimbus Web.
|
||||
* [JoeDocs](https://joedocs.com/) An open collaborative wiki.
|
||||
* [Pluxbox RadioManager](https://pluxbox.com/) A web-based app to
|
||||
collaboratively organize radio broadcasts.
|
||||
* [Cattaz](http://cattaz.io/) A wiki that can run custom applications in the
|
||||
wiki pages.
|
||||
* [Alldone](https://alldoneapp.com/) A next-gen project management and
|
||||
collaboration platform.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -33,15 +80,7 @@ release checkout the [v12 docs](./README.v12.md) :warning:
|
||||
* [Document Updates](#Document-Updates)
|
||||
* [Relative Positions](#Relative-Positions)
|
||||
* [Y.UndoManager](#YUndoManager)
|
||||
* [Miscellaneous](#Miscellaneous)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
|
||||
* [CRDT Algorithms](#CRDT-Algorithms)
|
||||
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
|
||||
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
|
||||
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
|
||||
* [License and Author](#License-and-Author)
|
||||
|
||||
## Overview
|
||||
@@ -54,13 +93,10 @@ are implemented in separate modules.
|
||||
|
||||
| Name | Cursors | Binding | Demo |
|
||||
|---|:-:|---|---|
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) |
|
||||
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/yjs/y-quill) | [demo](https://yjs-demos.now.sh/quill/) |
|
||||
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) |
|
||||
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/yjs/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
|
||||
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/yjs/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
|
||||
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/yjs/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [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) |
|
||||
|
||||
### Providers
|
||||
|
||||
@@ -70,20 +106,27 @@ manage all that for you and are the perfect starting point for your
|
||||
collaborative app.
|
||||
|
||||
<dl>
|
||||
<dt><a href="http://github.com/yjs/y-websocket">y-websocket</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
|
||||
are available. Communication over the signaling servers can be encrypted by
|
||||
providing a shared secret, keeping the connection information and the shared
|
||||
document private.
|
||||
</dd>
|
||||
<dt><a href="https://github.com/yjs/y-websocket">y-websocket</a></dt>
|
||||
<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-mesh">y-mesh</a></dt>
|
||||
<dt><a href="https://github.com/yjs/y-indexeddb">y-indexeddb</a></dt>
|
||||
<dd>
|
||||
[WIP] Creates a connected graph of webrtc connections with a high
|
||||
<a href="https://en.wikipedia.org/wiki/Strength_of_a_graph">strength</a>. It
|
||||
requires a signalling server that connects a client to the first peer. But after
|
||||
that the network manages itself. It is well suited for large and small networks.
|
||||
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
|
||||
@@ -97,7 +140,7 @@ hypercores and y-dat listens to changes and applies them to the Yjs document.
|
||||
Install Yjs and a provider with your favorite package manager:
|
||||
|
||||
```sh
|
||||
npm i yjs@13.0.0-97 y-websocket@1.0.0-6
|
||||
npm i yjs y-websocket
|
||||
```
|
||||
|
||||
Start the y-websocket server:
|
||||
@@ -135,6 +178,54 @@ Now you understand how types are defined on a shared document. Next you can jump
|
||||
to the [demo repository](https://github.com/yjs/yjs-demos) or continue reading
|
||||
the API docs.
|
||||
|
||||
### Example: Using and combining providers
|
||||
|
||||
Any of the Yjs providers can be combined with each other. So you can sync data
|
||||
over different network technologies.
|
||||
|
||||
In most cases you want to use a network provider (like y-websocket or y-webrtc)
|
||||
in combination with a persistence provider (y-indexeddb in the browser).
|
||||
Persistence allows you to load the document faster and to persist data that is
|
||||
created while offline.
|
||||
|
||||
For the sake of this demo we combine two different network providers with a
|
||||
persistence provider.
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebrtcProvider } from 'y-webrtc'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { IndexeddbPersistence } from 'y-indexeddb'
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
// this allows you to instantly get the (cached) documents data
|
||||
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
|
||||
indexeddbProvider.whenSynced.then(() => {
|
||||
console.log('loaded data from indexed db')
|
||||
})
|
||||
|
||||
// Sync clients with the y-webrtc provider.
|
||||
const webrtcProvider = new WebrtcProvider('count-demo', ydoc)
|
||||
|
||||
// Sync clients with the y-websocket provider
|
||||
const websocketProvider = new WebsocketProvider(
|
||||
'wss://demos.yjs.dev', 'count-demo', ydoc
|
||||
)
|
||||
|
||||
// array of numbers which produce a sum
|
||||
const yarray = ydoc.getArray('count')
|
||||
|
||||
// observe changes of the sum
|
||||
yarray.observe(event => {
|
||||
// print updates when the data changes
|
||||
console.log('new sum: ' + yarray.toArray().reduce((a,b) => a + b))
|
||||
})
|
||||
|
||||
// add 1 to the sum
|
||||
yarray.push([1]) // => "new sum: 1"
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
@@ -153,18 +244,24 @@ 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<object|boolean|Array|string|number|Uint8Array|Y.Type>)</code></b>
|
||||
<dd>
|
||||
Insert content at <var>index</var>. Note that content is an array of elements.
|
||||
I.e. <code>array.insert(0, [1]</code> splices the list and inserts 1 at
|
||||
I.e. <code>array.insert(0, [1])</code> splices the list and inserts 1 at
|
||||
position 0.
|
||||
</dd>
|
||||
<b><code>push(Array<Object|boolean|Array|string|number|Uint8Array|Y.Type>)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>unshift(Array<Object|boolean|Array|string|number|Uint8Array|Y.Type>)</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<Object|boolean|Array|string|number|Uint8Array|Y.Type></code></b>
|
||||
<dd>Retrieve a range of content</dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b>
|
||||
@@ -220,6 +317,8 @@ 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>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>
|
||||
@@ -230,12 +329,15 @@ 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<string, Object|boolean|Array|string|number|Uint8Array></code></b>
|
||||
<dd>
|
||||
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
|
||||
transforms all child types to JSON using their <code>toJSON</code> method.
|
||||
</dd>
|
||||
<b><code>forEach(function(key:string,value:object|boolean|Array|string|number|Uint8Array|Y.Type))</code></b>
|
||||
<b><code>forEach(function(value:object|boolean|Array|string|number|Uint8Array|Y.Type,
|
||||
key:string, map: Y.Map))</code></b>
|
||||
<dd>
|
||||
Execute the provided function once for every key-value pair.
|
||||
</dd>
|
||||
@@ -296,6 +398,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<string,string>])</code></b>
|
||||
<dd>
|
||||
Insert a string at <var>index</var> and assign formatting attributes to it.
|
||||
@@ -305,8 +409,12 @@ YTextEvents compute changes as deltas.
|
||||
<dd></dd>
|
||||
<b><code>format(index:number, length:number, formattingAttributes:Object<string,string>)</code></b>
|
||||
<dd>Assign formatting attributes to a range in the text</dd>
|
||||
<b><code>applyDelta(delta)</code></b>
|
||||
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
|
||||
<b><code>applyDelta(delta, opts:Object<string,any>)</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.
|
||||
<pre>ytext.applyDelta(delta, { sanitize: false })</pre>
|
||||
</dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toString():string</code></b>
|
||||
@@ -343,21 +451,29 @@ or any of its children.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>YXmlFragment</b></summary>
|
||||
<summary><b>Y.XmlFragment</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A container that holds an Array of Y.XmlElements.
|
||||
</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<Y.XmlElement|Y.XmlText>)</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<Y.XmlElement|Y.XmlText></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<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():DocumentFragment</code></b>
|
||||
@@ -401,6 +517,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<Y.XmlElement|Y.XmlText>)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
@@ -417,6 +541,12 @@ content and be actually XML compliant.
|
||||
<dd></dd>
|
||||
<b><code>getAttributes(attributeName:string):Object<string,string></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<Y.XmlElement|Y.XmlText></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<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():Element</code></b>
|
||||
@@ -460,6 +590,12 @@ const doc = new Y.Doc()
|
||||
<dl>
|
||||
<b><code>clientID</code></b>
|
||||
<dd>A unique id that identifies this client. (readonly)</dd>
|
||||
<b><code>gc</code></b>
|
||||
<dd>
|
||||
Whether garbage collection is enabled on this doc instance. Set `doc.gc = false`
|
||||
in order to disable gc and be able to restore old content. See https://github.com/yjs/yjs#yjs-crdt-algorithm
|
||||
for more information about gc in Yjs.
|
||||
</dd>
|
||||
<b><code>transact(function(Transaction):void [, origin:any])</code></b>
|
||||
<dd>
|
||||
Every change on the shared document happens in a transaction. Observer calls and
|
||||
@@ -469,6 +605,10 @@ calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code>
|
||||
triggers a single change event. <br>You can specify an optional <code>origin</code>
|
||||
parameter that is stored on <code>transaction.origin</code> and
|
||||
<code>on('update', (update, origin) => ..)</code>.
|
||||
</dd>
|
||||
<b><code>toJSON():any</code></b>
|
||||
<dd>
|
||||
Converts the entire document into a js object, recursively traversing each yjs type.
|
||||
</dd>
|
||||
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
|
||||
<dd>Define a shared type.</dd>
|
||||
@@ -496,6 +636,13 @@ peers. You can apply document updates in any order and multiple times.
|
||||
<dd>Emitted before each transaction.</dd>
|
||||
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
|
||||
<dd>Emitted after each transaction.</dd>
|
||||
<b><code>on('beforeAllTransactions', function(Y.Doc):void)</code></b>
|
||||
<dd>
|
||||
Transactions can be nested (e.g. when an event within a transaction calls another
|
||||
transaction). Emitted before the first transaction.
|
||||
</dd>
|
||||
<b><code>on('afterAllTransactions', function(Y.Doc, Array<Y.Transaction>):void)</code></b>
|
||||
<dd>Emitted after the last transaction is cleaned up.</dd>
|
||||
</dl>
|
||||
|
||||
### Document Updates
|
||||
@@ -636,7 +783,7 @@ Yjs ships with an Undo/Redo manager for selective undo/redo of of changes on a
|
||||
Yjs type. The changes can be optionally scoped to transaction origins.
|
||||
|
||||
```js
|
||||
const ytext = doc.getArray('array')
|
||||
const ytext = doc.getText('text')
|
||||
const undoManager = new Y.UndoManager(ytext)
|
||||
|
||||
ytext.insert(0, 'abc')
|
||||
@@ -647,8 +794,8 @@ ytext.toString() // => 'abc'
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>constructor(scope:Y.AbstractType|Array<Y.AbstractType>,
|
||||
[[{captureTimeout:number,trackedOrigins:Set<any>,deleteFilter:function(item):boolean}]])</code></b>
|
||||
<b><code>constructor(scope:Y.AbstractType|Array<Y.AbstractType>
|
||||
[, {captureTimeout:number,trackedOrigins:Set<any>,deleteFilter:function(item):boolean}])</code></b>
|
||||
<dd>Accepts either single type as scope or an array of types.</dd>
|
||||
<b><code>undo()</code></b>
|
||||
<dd></dd>
|
||||
@@ -670,7 +817,7 @@ undo- or the redo-stack.
|
||||
<code>
|
||||
on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo'
|
||||
| 'redo' })
|
||||
</code>
|
||||
</code>
|
||||
</b>
|
||||
<dd>
|
||||
Register an event that is called when a <code>StackItem</code> is popped from
|
||||
@@ -688,28 +835,30 @@ StackItem won't be merged.
|
||||
// without stopCapturing
|
||||
ytext.insert(0, 'a')
|
||||
ytext.insert(1, 'b')
|
||||
um.undo()
|
||||
undoManager.undo()
|
||||
ytext.toString() // => '' (note that 'ab' was removed)
|
||||
// with stopCapturing
|
||||
ytext.insert(0, 'a')
|
||||
um.stopCapturing()
|
||||
undoManager.stopCapturing()
|
||||
ytext.insert(0, 'b')
|
||||
um.undo()
|
||||
undoManager.undo()
|
||||
ytext.toString() // => 'a' (note that only 'b' was removed)
|
||||
```
|
||||
|
||||
#### Example: Specify tracked origins
|
||||
|
||||
Every change on the shared document has an origin. If no origin was specified,
|
||||
it defaults to `null`. By specifying `trackedTransactionOrigins` you can
|
||||
it defaults to `null`. By specifying `trackedOrigins` you can
|
||||
selectively specify which changes should be tracked by `UndoManager`. The
|
||||
UndoManager instance is always added to `trackedTransactionOrigins`.
|
||||
UndoManager instance is always added to `trackedOrigins`.
|
||||
|
||||
```js
|
||||
class CustomBinding {}
|
||||
|
||||
const ytext = doc.getArray('array')
|
||||
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding]))
|
||||
const ytext = doc.getText('text')
|
||||
const undoManager = new Y.UndoManager(ytext, {
|
||||
trackedOrigins: new Set([42, CustomBinding])
|
||||
})
|
||||
|
||||
ytext.insert(0, 'abc')
|
||||
undoManager.undo()
|
||||
@@ -746,8 +895,10 @@ additional meta information like the cursor location or the view on the
|
||||
document. You can assign meta-information to Undo-/Redo-StackItems.
|
||||
|
||||
```js
|
||||
const ytext = doc.getArray('array')
|
||||
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding]))
|
||||
const ytext = doc.getText('text')
|
||||
const undoManager = new Y.UndoManager(ytext, {
|
||||
trackedOrigins: new Set([42, CustomBinding])
|
||||
})
|
||||
|
||||
undoManager.on('stack-item-added', event => {
|
||||
// save the current cursor location on the stack-item
|
||||
@@ -760,24 +911,6 @@ undoManager.on('stack-item-popped', event => {
|
||||
})
|
||||
```
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
### Typescript Declarations
|
||||
|
||||
Yjs has type descriptions. But until [this
|
||||
ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is
|
||||
how you can make use of Yjs type declarations.
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
},
|
||||
"maxNodeModuleJsDepth": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Yjs CRDT Algorithm
|
||||
|
||||
*Conflict-free replicated data types* (CRDT) for collaborative editing are an
|
||||
@@ -794,11 +927,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
|
||||
@@ -851,5 +987,6 @@ Yjs and all related projects are [**MIT licensed**](./LICENSE).
|
||||
Yjs is based on my research as a student at the [RWTH
|
||||
i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
|
||||
|
||||
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or
|
||||
hiring [me](https://github.com/dmonad) for professional support.
|
||||
Fund this project by donating on [GitHub Sponsors](https://github.com/sponsors/dmonad)
|
||||
or hiring [me](https://github.com/dmonad) as a contractor for your collaborative
|
||||
app.
|
||||
|
||||
305
README.v12.md
305
README.v12.md
@@ -1,305 +0,0 @@
|
||||
|
||||
# 
|
||||
|
||||
Yjs is a framework for offline-first p2p shared editing on structured data like
|
||||
text, richtext, json, or XML. It is fairly easy to get started, as Yjs hides
|
||||
most of the complexity of concurrent editing. For additional information, demos,
|
||||
and tutorials visit [y-js.org](http://y-js.org/).
|
||||
|
||||
:warning: Checkout the [v13 docs](./README.md) for the upcoming release :warning:
|
||||
|
||||
### Extensions
|
||||
Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
|
||||
|
||||
* *Connector* - a communication protocol that propagates changes to the clients
|
||||
* *Database* - a database to store your changes
|
||||
* one or more *Types* - that represent the shared data
|
||||
|
||||
Connectors, Databases, and Types are available as modules that extend Yjs. Here
|
||||
is a list of the modules we know of:
|
||||
|
||||
##### Connectors
|
||||
|
||||
|Name | Description |
|
||||
|----------------|-----------------------------------|
|
||||
|[webrtc](https://github.com/y-js/y-webrtc) | Propagate updates Browser2Browser via WebRTC|
|
||||
|[websockets](https://github.com/y-js/y-websockets-client) | Set up [a central server](https://github.com/y-js/y-websockets-client), and connect to it via websockets |
|
||||
|[xmpp](https://github.com/y-js/y-xmpp) | Propagate updates in a XMPP multi-user-chat room ([XEP-0045](http://xmpp.org/extensions/xep-0045.html))|
|
||||
|[ipfs](https://github.com/ipfs-labs/y-ipfs-connector) | Connector for the [Interplanetary File System](https://ipfs.io/)!|
|
||||
|[test](https://github.com/y-js/y-test) | A Connector for testing purposes. It is designed to simulate delays that happen in worst case scenarios|
|
||||
|
||||
##### Database adapters
|
||||
|
||||
|Name | Description |
|
||||
|----------------|-----------------------------------|
|
||||
|[memory](https://github.com/y-js/y-memory) | In-memory storage. |
|
||||
|[indexeddb](https://github.com/y-js/y-indexeddb) | Offline storage for the browser |
|
||||
|[leveldb](https://github.com/y-js/y-leveldb) | Persistent storage for node apps |
|
||||
|
||||
##### Types
|
||||
|
||||
| Name | Description |
|
||||
|----------|-------------------|
|
||||
|[map](https://github.com/y-js/y-map) | A shared Map implementation. Maps from text to any stringify-able object |
|
||||
|[array](https://github.com/y-js/y-array) | A shared Array implementation |
|
||||
|[xml](https://github.com/y-js/y-xml) | An implementation of the DOM. You can create a two way binding to Browser DOM objects |
|
||||
|[text](https://github.com/y-js/y-text) | Collaborate on text. Supports two way binding to the [Ace Editor](https://ace.c9.io), [CodeMirror](https://codemirror.net/), [Monaco](https://github.com/Microsoft/monaco-editor), textareas, input elements, and HTML elements (e.g. <*h1*>, or <*p*>) |
|
||||
|[richtext](https://github.com/y-js/y-richtext) | Collaborate on rich text. Supports two way binding to the [Quill Rich Text Editor](http://quilljs.com/)|
|
||||
|
||||
##### Other
|
||||
|
||||
| Name | Description |
|
||||
|-----------|-------------------|
|
||||
|[y-element](http://y-js.org/y-element/) | Yjs Polymer Element |
|
||||
|
||||
## Use it!
|
||||
Install Yjs, and its modules with [bower](http://bower.io/), or
|
||||
[npm](https://www.npmjs.org/package/yjs).
|
||||
|
||||
### Bower
|
||||
|
||||
```
|
||||
bower install --save yjs y-array % add all y-* modules you want to use
|
||||
```
|
||||
You only need to include the `y.js` file. Yjs is able to automatically require
|
||||
missing modules.
|
||||
```
|
||||
<script src="./bower_components/yjs/y.js"></script>
|
||||
```
|
||||
|
||||
### CDN
|
||||
|
||||
```
|
||||
<script src="https://cdn.jsdelivr.net/npm/yjs@12/dist/y.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/y-array@10/dist/y-array.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/y-websockets-client@8/dist/y-websockets-client.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/y-memory@8/dist/y-memory.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/y-map@10/dist/y-map.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/y-text@9/dist/y-text.js"></script>
|
||||
// ..
|
||||
// do the same for all modules you want to use
|
||||
```
|
||||
|
||||
### Npm
|
||||
|
||||
```
|
||||
npm install --save yjs % add all y-* modules you want to use
|
||||
```
|
||||
|
||||
If you don't include via script tag, you have to explicitly include all modules!
|
||||
(Same goes for other module systems)
|
||||
```
|
||||
var Y = require('yjs')
|
||||
require('y-array')(Y) // add the y-array type to Yjs
|
||||
require('y-websockets-client')(Y)
|
||||
require('y-memory')(Y)
|
||||
require('y-map')(Y)
|
||||
require('y-text')(Y)
|
||||
// ..
|
||||
// do the same for all modules you want to use
|
||||
```
|
||||
|
||||
### ES6 Syntax
|
||||
|
||||
```
|
||||
import Y from 'yjs'
|
||||
import yArray from 'y-array'
|
||||
import yWebsocketsClient from 'y-webrtc'
|
||||
import yMemory from 'y-memory'
|
||||
import yMap from 'y-map'
|
||||
import yText from 'y-text'
|
||||
// ..
|
||||
Y.extend(yArray, yWebsocketsClient, yMemory, yArray, yMap, yText /*, .. */)
|
||||
```
|
||||
|
||||
# Text editing example
|
||||
|
||||
Install dependencies
|
||||
```
|
||||
bower i yjs y-memory y-webrtc y-array y-text
|
||||
```
|
||||
|
||||
Here is a simple example of a shared textarea
|
||||
```HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script src="./bower_components/yjs/y.js"></script>
|
||||
<!-- Yjs automatically includes all missing dependencies (browser only) -->
|
||||
<script>
|
||||
Y({
|
||||
db: {
|
||||
name: 'memory' // use memory database adapter.
|
||||
// name: 'indexeddb' // use indexeddb database adapter instead for offline apps
|
||||
},
|
||||
connector: {
|
||||
name: 'webrtc', // use webrtc connector
|
||||
// name: 'websockets-client'
|
||||
// name: 'xmpp'
|
||||
room: 'my-room' // clients connecting to the same room share data
|
||||
},
|
||||
sourceDir: './bower_components', // location of the y-* modules (browser only)
|
||||
share: {
|
||||
textarea: 'Text' // y.share.textarea is of type y-text
|
||||
}
|
||||
}).then(function (y) {
|
||||
// The Yjs instance `y` is available
|
||||
// y.share.* contains the shared types
|
||||
|
||||
// Bind `y.share.textarea` to `<textarea/>`
|
||||
y.share.textarea.bind(document.querySelector('textarea'))
|
||||
})
|
||||
</script>
|
||||
<textarea></textarea>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Get Help & Give Help
|
||||
There are some friendly people on [](https://gitter.im/y-js/yjs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) who are eager to help, and answer questions. Please join!
|
||||
|
||||
Report _any_ issues to the
|
||||
[Github issue page](https://github.com/y-js/yjs/issues)! I try to fix them very
|
||||
soon, if possible.
|
||||
|
||||
# API
|
||||
|
||||
### Y(options)
|
||||
* Y.extend(module1, module2, ..)
|
||||
* Add extensions to Y
|
||||
* `Y.extend(require('y-webrtc'))` has the same semantics as
|
||||
`require('y-webrtc')(Y)`
|
||||
* options.db
|
||||
* Will be forwarded to the database adapter. Specify the database adaper on
|
||||
`options.db.name`.
|
||||
* Have a look at the used database adapter repository to see all available
|
||||
options.
|
||||
* options.connector
|
||||
* Will be forwarded to the connector adapter. Specify the connector adaper on
|
||||
`options.connector.name`.
|
||||
* All our connectors implement a `room` property. Clients that specify the
|
||||
same room share the same data.
|
||||
* All of our connectors specify an `url` property that defines the connection
|
||||
endpoint of the used connector.
|
||||
* All of our connectors also have a default connection endpoint that you can
|
||||
use for development.
|
||||
* Set `options.connector.generateUserId = true` in order to genenerate a
|
||||
userid, instead of receiving one from the server. This way the `Y(..)` is
|
||||
immediately going to be resolved, without waiting for any confirmation from
|
||||
the server. Use with caution.
|
||||
* Have a look at the used connector repository to see all available options.
|
||||
* *Only if you know what you are doing:* Set
|
||||
`options.connector.preferUntransformed = true` in order receive the shared
|
||||
data untransformed. This is very efficient as the database content is simply
|
||||
copied to this client. This does only work if this client receives content
|
||||
from only one client.
|
||||
* options.sourceDir (browser only)
|
||||
* Path where all y-* modules are stored
|
||||
* Defaults to `/bower_components`
|
||||
* Not required when running on `nodejs` / `iojs`
|
||||
* When using nodejs you need to manually extend Yjs:
|
||||
```
|
||||
var Y = require('yjs')
|
||||
// you have to require a db, connector, and *all* types you use!
|
||||
require('y-memory')(Y)
|
||||
require('y-webrtc')(Y)
|
||||
require('y-map')(Y)
|
||||
// ..
|
||||
```
|
||||
* options.share
|
||||
* Specify on `options.share[arbitraryName]` types that are shared among all
|
||||
users.
|
||||
* E.g. Specify `options.share[arbitraryName] = 'Array'` to require y-array and
|
||||
create an y-array type on `y.share[arbitraryName]`.
|
||||
* If userA doesn't specify `options.share[arbitraryName]`, it won't be
|
||||
available for userA.
|
||||
* If userB specifies `options.share[arbitraryName]`, it still won't be
|
||||
available for userA. But all the updates are send from userB to userA.
|
||||
* In contrast to y-map, types on `y.share.*` cannot be overwritten or deleted.
|
||||
Instead, they are merged among all users. This feature is only available on
|
||||
`y.share.*`
|
||||
* Weird behavior: It is supported that two users specify different types with
|
||||
the same property name.
|
||||
E.g. userA specifies `options.share.x = 'Array'`, and userB specifies
|
||||
`options.share.x = 'Text'`. But they only share data if they specified the
|
||||
same type with the same property name
|
||||
* options.type (browser only)
|
||||
* Array of modules that Yjs needs to require, before instantiating a shared
|
||||
type.
|
||||
* By default Yjs requires the specified database adapter, the specified
|
||||
connector, and all modules that are used in `options.share.*`
|
||||
* Put all types here that you intend to use, but are not used in y.share.*
|
||||
|
||||
### Instantiated Y object (y)
|
||||
`Y(options)` returns a promise that is fulfilled when..
|
||||
|
||||
* All modules are loaded
|
||||
* The specified database adapter is loaded
|
||||
* The specified connector is loaded
|
||||
* All types are included
|
||||
* The connector is initialized, and a unique user id is set (received from the
|
||||
server)
|
||||
* Note: When using y-indexeddb, a retrieved user id is stored on `localStorage`
|
||||
|
||||
The promise returns an instance of Y. We denote it with a lower case `y`.
|
||||
|
||||
* y.share.*
|
||||
* Instances of the types you specified on options.share.*
|
||||
* y.share.* can only be defined once when you instantiate Y!
|
||||
* y.connector is an instance of Y.AbstractConnector
|
||||
* y.connector.onUserEvent(function (event) {..})
|
||||
* Observe user events (event.action is either 'userLeft' or 'userJoined')
|
||||
* y.connector.whenSynced(listener)
|
||||
* `listener` is executed when y synced with at least one user.
|
||||
* `listener` is not called when no other user is in the same room.
|
||||
* y-websockets-client aways waits to sync with the server
|
||||
* y.connector.disconnect()
|
||||
* Force to disconnect this instance from the other instances
|
||||
* y.connector.connect()
|
||||
* Try to reconnect to the other instances (needs to be supported by the
|
||||
connector)
|
||||
* Not supported by y-xmpp
|
||||
* y.close()
|
||||
* Destroy this object.
|
||||
* Destroys all types (they will throw weird errors if you still use them)
|
||||
* Disconnects from the other instances (via connector)
|
||||
* Returns a promise
|
||||
* y.destroy()
|
||||
* calls y.close()
|
||||
* Removes all data from the database
|
||||
* Returns a promise
|
||||
* y.db.stopGarbageCollector()
|
||||
* Stop the garbage collector. Call y.db.garbageCollect() to continue garbage
|
||||
collection
|
||||
* y.db.gc :: Boolean
|
||||
* Whether gc is turned on
|
||||
* y.db.gcTimeout :: Number (defaults to 50000 ms)
|
||||
* Time interval between two garbage collect cycles
|
||||
* It is required that all instances exchanged all messages after two garbage
|
||||
collect cycles (after 100000 ms per default)
|
||||
* y.db.userId :: String
|
||||
* The used user id for this client. **Never overwrite this**
|
||||
|
||||
### Logging
|
||||
Yjs uses [debug](https://github.com/visionmedia/debug) for logging. The flag
|
||||
`y*` enables logging for all y-* components. You can selectively remove
|
||||
components you are not interested in: E.g. The flag `y*,-y:connector-message`
|
||||
will not log the long `y:connector-message` messages.
|
||||
|
||||
##### Enable logging in Node.js
|
||||
```sh
|
||||
DEBUG=y* node app.js
|
||||
```
|
||||
|
||||
Remove the colors in order to log to a file:
|
||||
```sh
|
||||
DEBUG_COLORS=0 DEBUG=y* node app.js > log
|
||||
```
|
||||
|
||||
##### Enable logging in the browser
|
||||
```js
|
||||
localStorage.debug = 'y*'
|
||||
```
|
||||
|
||||
## License
|
||||
Yjs is licensed under the [MIT License](./LICENSE).
|
||||
4014
package-lock.json
generated
4014
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-99",
|
||||
"version": "13.4.12",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.js",
|
||||
"main": "./dist/yjs.cjs",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"unpkg": "./dist/yjs.mjs",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
|
||||
"dist": "rm -rf dist && rollup -c",
|
||||
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
|
||||
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
|
||||
"dist": "rm -rf dist && rollup -c && tsc",
|
||||
"watch": "rollup -wc",
|
||||
"lint": "markdownlint README.md && standard && tsc",
|
||||
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
|
||||
"serve-docs": "npm run docs && serve ./docs/",
|
||||
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
|
||||
"postversion": "git push && git push --tags",
|
||||
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
|
||||
"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",
|
||||
"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",
|
||||
"postinstall": "node ./sponsor-y.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
"src/*",
|
||||
"tests/*",
|
||||
"docs/*"
|
||||
"docs/*",
|
||||
"sponsor-y.js"
|
||||
],
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
@@ -41,7 +48,12 @@
|
||||
"url": "https://github.com/yjs/yjs.git"
|
||||
},
|
||||
"keywords": [
|
||||
"crdt"
|
||||
"Yjs",
|
||||
"CRDT",
|
||||
"offline",
|
||||
"shared editing",
|
||||
"concurrency",
|
||||
"collaboration"
|
||||
],
|
||||
"author": "Kevin Jahns",
|
||||
"email": "kevin.jahns@protonmail.com",
|
||||
@@ -49,20 +61,21 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/yjs/yjs/issues"
|
||||
},
|
||||
"homepage": "https://yjs.dev",
|
||||
"homepage": "https://docs.yjs.dev",
|
||||
"dependencies": {
|
||||
"lib0": "^0.1.1"
|
||||
"lib0": "^0.2.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||
"concurrently": "^3.6.1",
|
||||
"jsdoc": "^3.6.3",
|
||||
"live-server": "^1.2.1",
|
||||
"rollup": "^1.20.3",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-node-resolve": "^4.2.4",
|
||||
"standard": "^11.0.1",
|
||||
"http-server": "^0.12.3",
|
||||
"jsdoc": "^3.6.6",
|
||||
"markdownlint-cli": "^0.23.2",
|
||||
"rollup": "^2.36.1",
|
||||
"standard": "^14.3.4",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.6.2",
|
||||
"y-protocols": "0.0.6"
|
||||
"typescript": "^3.9.7",
|
||||
"y-protocols": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import nodeResolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
|
||||
const localImports = process.env.LOCALIMPORTS
|
||||
|
||||
@@ -37,23 +38,27 @@ const debugResolve = {
|
||||
|
||||
export default [{
|
||||
input: './src/index.js',
|
||||
output: [{
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.js',
|
||||
file: 'dist/yjs.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
paths: path => {
|
||||
if (/^lib0\//.test(path)) {
|
||||
return `lib0/dist/${path.slice(5)}`
|
||||
return `lib0/dist/${path.slice(5, -3)}.cjs`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}, {
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './src/index.js',
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.mjs',
|
||||
format: 'es',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
}],
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
@@ -66,8 +71,24 @@ export default [{
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
})
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
mainFields: ['module', 'main']
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
external: ['isomorphic.js']
|
||||
}]
|
||||
|
||||
10
sponsor-y.js
Normal file
10
sponsor-y.js
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
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')
|
||||
22
src/index.js
22
src/index.js
@@ -12,6 +12,7 @@ export {
|
||||
YXmlEvent,
|
||||
YMapEvent,
|
||||
YArrayEvent,
|
||||
YTextEvent,
|
||||
YEvent,
|
||||
Item,
|
||||
AbstractStruct,
|
||||
@@ -26,12 +27,11 @@ export {
|
||||
ContentType,
|
||||
AbstractType,
|
||||
RelativePosition,
|
||||
getTypeChildren,
|
||||
createRelativePositionFromTypeIndex,
|
||||
createRelativePositionFromJSON,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
compareRelativePositions,
|
||||
writeRelativePosition,
|
||||
readRelativePosition,
|
||||
ID,
|
||||
createID,
|
||||
compareIDs,
|
||||
@@ -43,16 +43,32 @@ export {
|
||||
snapshot,
|
||||
emptySnapshot,
|
||||
findRootTypeKey,
|
||||
getItem,
|
||||
typeListToArraySnapshot,
|
||||
typeMapGetSnapshot,
|
||||
createDocFromSnapshot,
|
||||
iterateDeletedStructs,
|
||||
applyUpdate,
|
||||
applyUpdateV2,
|
||||
readUpdate,
|
||||
readUpdateV2,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateAsUpdateV2,
|
||||
encodeStateVector,
|
||||
encodeStateVectorV2,
|
||||
UndoManager,
|
||||
decodeSnapshot,
|
||||
encodeSnapshot,
|
||||
decodeSnapshotV2,
|
||||
encodeSnapshotV2,
|
||||
decodeStateVector,
|
||||
decodeStateVectorV2,
|
||||
isDeleted,
|
||||
isParentOf,
|
||||
equalSnapshots,
|
||||
PermanentUserData // @TODO experimental
|
||||
PermanentUserData, // @TODO experimental
|
||||
tryGc,
|
||||
transact,
|
||||
AbstractConnector,
|
||||
logType
|
||||
} from './internals.js'
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
|
||||
export * from './utils/AbstractConnector.js'
|
||||
export * from './utils/DeleteSet.js'
|
||||
export * from './utils/Doc.js'
|
||||
export * from './utils/UpdateDecoder.js'
|
||||
export * from './utils/UpdateEncoder.js'
|
||||
export * from './utils/encoding.js'
|
||||
export * from './utils/EventHandler.js'
|
||||
export * from './utils/ID.js'
|
||||
export * from './utils/isParentOf.js'
|
||||
export * from './utils/logging.js'
|
||||
export * from './utils/PermanentUserData.js'
|
||||
export * from './utils/RelativePosition.js'
|
||||
export * from './utils/Snapshot.js'
|
||||
@@ -27,6 +31,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'
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
|
||||
import {
|
||||
StructStore, ID, Transaction // eslint-disable-line
|
||||
AbstractUpdateEncoder, ID, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class AbstractStruct {
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor (id, length) {
|
||||
/**
|
||||
* The uniqe identifier of this struct.
|
||||
* @type {ID}
|
||||
* @readonly
|
||||
*/
|
||||
this.id = id
|
||||
this.length = length
|
||||
this.deleted = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
get deleted () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge this struct with the item to the right.
|
||||
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
|
||||
@@ -34,55 +32,21 @@ export class AbstractStruct {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
* @param {number} encodingRef
|
||||
* @private
|
||||
*/
|
||||
write (encoder, offset, encodingRef) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
integrate (transaction) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class AbstractStructRef {
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
constructor (id) {
|
||||
/**
|
||||
* @type {Array<ID>}
|
||||
*/
|
||||
this._missing = []
|
||||
/**
|
||||
* The uniqe identifier of this type.
|
||||
* @type {ID}
|
||||
*/
|
||||
this.id = id
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @return {Array<ID|null>}
|
||||
*/
|
||||
getMissing (transaction) {
|
||||
return this._missing
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {AbstractStruct}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
integrate (transaction, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
Transaction, Item, StructStore // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ContentAny {
|
||||
/**
|
||||
* @param {Array<any>} arr
|
||||
@@ -18,30 +12,35 @@ export class ContentAny {
|
||||
*/
|
||||
this.arr = arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.arr.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentAny(this.arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentAny}
|
||||
@@ -51,6 +50,7 @@ export class ContentAny {
|
||||
this.arr = this.arr.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentAny} right
|
||||
* @return {boolean}
|
||||
@@ -59,6 +59,7 @@ export class ContentAny {
|
||||
this.arr = this.arr.concat(right.arr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -73,17 +74,18 @@ export class ContentAny {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const len = this.arr.length
|
||||
encoding.writeVarUint(encoder, len - offset)
|
||||
encoder.writeLen(len - offset)
|
||||
for (let i = offset; i < len; i++) {
|
||||
const c = this.arr[i]
|
||||
encoding.writeAny(encoder, c)
|
||||
encoder.writeAny(c)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -93,16 +95,14 @@ export class ContentAny {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
export const readContentAny = decoder => {
|
||||
const len = decoding.readVarUint(decoder)
|
||||
const len = decoder.readLen()
|
||||
const cs = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
cs.push(decoding.readAny(decoder))
|
||||
cs.push(decoder.readAny())
|
||||
}
|
||||
return new ContentAny(cs)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
StructStore, Item, Transaction // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as buffer from 'lib0/buffer.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ContentBinary {
|
||||
/**
|
||||
* @param {Uint8Array} content
|
||||
@@ -17,30 +11,35 @@ export class ContentBinary {
|
||||
constructor (content) {
|
||||
this.content = content
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.content]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentBinary}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentBinary(this.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentBinary}
|
||||
@@ -48,6 +47,7 @@ export class ContentBinary {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentBinary} right
|
||||
* @return {boolean}
|
||||
@@ -55,6 +55,7 @@ export class ContentBinary {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -69,12 +70,13 @@ export class ContentBinary {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarUint8Array(encoder, this.content)
|
||||
encoder.writeBuf(this.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -84,9 +86,7 @@ export class ContentBinary {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentBinary}
|
||||
*/
|
||||
export const readContentBinary = decoder => new ContentBinary(buffer.copyUint8Array(decoding.readVarUint8Array(decoder)))
|
||||
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
|
||||
import {
|
||||
addToDeleteSet,
|
||||
StructStore, Item, Transaction // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ContentDeleted {
|
||||
/**
|
||||
* @param {number} len
|
||||
@@ -17,30 +11,35 @@ export class ContentDeleted {
|
||||
constructor (len) {
|
||||
this.len = len
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.len
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentDeleted}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentDeleted(this.len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentDeleted}
|
||||
@@ -50,6 +49,7 @@ export class ContentDeleted {
|
||||
this.len = offset
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentDeleted} right
|
||||
* @return {boolean}
|
||||
@@ -58,14 +58,16 @@ export class ContentDeleted {
|
||||
this.len += right.len
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
*/
|
||||
integrate (transaction, item) {
|
||||
addToDeleteSet(transaction.deleteSet, item.id, this.len)
|
||||
item.deleted = true
|
||||
addToDeleteSet(transaction.deleteSet, item.id.client, item.id.clock, this.len)
|
||||
item.markDeleted()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
@@ -75,12 +77,13 @@ export class ContentDeleted {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarUint(encoder, this.len - offset)
|
||||
encoder.writeLen(this.len - offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -92,7 +95,7 @@ export class ContentDeleted {
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentDeleted}
|
||||
*/
|
||||
export const readContentDeleted = decoder => new ContentDeleted(decoding.readVarUint(decoder))
|
||||
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())
|
||||
|
||||
135
src/structs/ContentDoc.js
Normal file
135
src/structs/ContentDoc.js
Normal file
@@ -0,0 +1,135 @@
|
||||
|
||||
import {
|
||||
Doc, AbstractUpdateDecoder, AbstractUpdateEncoder, 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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeString(this.doc.guid)
|
||||
encoder.writeAny(this.opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 9
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentDoc}
|
||||
*/
|
||||
export const readContentDoc = decoder => new ContentDoc(new Doc({ guid: decoder.readString(), ...decoder.readAny() }))
|
||||
@@ -1,10 +1,8 @@
|
||||
|
||||
import {
|
||||
StructStore, Item, Transaction // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
@@ -17,30 +15,35 @@ export class ContentEmbed {
|
||||
constructor (embed) {
|
||||
this.embed = embed
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.embed]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentEmbed}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentEmbed(this.embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentEmbed}
|
||||
@@ -48,6 +51,7 @@ export class ContentEmbed {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentEmbed} right
|
||||
* @return {boolean}
|
||||
@@ -55,6 +59,7 @@ export class ContentEmbed {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -69,12 +74,13 @@ export class ContentEmbed {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarString(encoder, JSON.stringify(this.embed))
|
||||
encoder.writeJSON(this.embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -86,7 +92,7 @@ export class ContentEmbed {
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentEmbed}
|
||||
*/
|
||||
export const readContentEmbed = decoder => new ContentEmbed(JSON.parse(decoding.readVarString(decoder)))
|
||||
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
|
||||
import {
|
||||
Item, StructStore, Transaction // eslint-disable-line
|
||||
AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Item, StructStore, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
@@ -19,30 +17,35 @@ export class ContentFormat {
|
||||
this.key = key
|
||||
this.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentFormat(this.key, this.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentFormat}
|
||||
@@ -50,6 +53,7 @@ export class ContentFormat {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentFormat} right
|
||||
* @return {boolean}
|
||||
@@ -57,11 +61,16 @@ export class ContentFormat {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
*/
|
||||
integrate (transaction, item) {}
|
||||
integrate (transaction, item) {
|
||||
// @todo searchmarker are currently unsupported for rich text documents
|
||||
/** @type {AbstractType<any>} */ (item.parent)._searchMarker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
@@ -71,13 +80,14 @@ export class ContentFormat {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarString(encoder, this.key)
|
||||
encoding.writeVarString(encoder, JSON.stringify(this.value))
|
||||
encoder.writeKey(this.key)
|
||||
encoder.writeJSON(this.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -87,9 +97,7 @@ export class ContentFormat {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
export const readContentFormat = decoder => new ContentFormat(decoding.readVarString(decoder), JSON.parse(decoding.readVarString(decoder)))
|
||||
export const readContentFormat = decoder => new ContentFormat(decoder.readString(), decoder.readJSON())
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Transaction, Item, StructStore // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -18,30 +15,35 @@ export class ContentJSON {
|
||||
*/
|
||||
this.arr = arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.arr.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentJSON}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentJSON(this.arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentJSON}
|
||||
@@ -51,6 +53,7 @@ export class ContentJSON {
|
||||
this.arr = this.arr.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentJSON} right
|
||||
* @return {boolean}
|
||||
@@ -59,6 +62,7 @@ export class ContentJSON {
|
||||
this.arr = this.arr.concat(right.arr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -73,17 +77,18 @@ export class ContentJSON {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const len = this.arr.length
|
||||
encoding.writeVarUint(encoder, len - offset)
|
||||
encoder.writeLen(len - offset)
|
||||
for (let i = offset; i < len; i++) {
|
||||
const c = this.arr[i]
|
||||
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
|
||||
encoder.writeString(c === undefined ? 'undefined' : JSON.stringify(c))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -95,14 +100,14 @@ export class ContentJSON {
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentJSON}
|
||||
*/
|
||||
export const readContentJSON = decoder => {
|
||||
const len = decoding.readVarUint(decoder)
|
||||
const len = decoder.readLen()
|
||||
const cs = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
const c = decoding.readVarString(decoder)
|
||||
const c = decoder.readString()
|
||||
if (c === 'undefined') {
|
||||
cs.push(undefined)
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Transaction, Item, StructStore // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -18,30 +15,35 @@ export class ContentString {
|
||||
*/
|
||||
this.str = str
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.str.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.str.split('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentString}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentString(this.str)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentString}
|
||||
@@ -49,8 +51,20 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentString} right
|
||||
* @return {boolean}
|
||||
@@ -59,6 +73,7 @@ export class ContentString {
|
||||
this.str += right.str
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -73,12 +88,13 @@ export class ContentString {
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarString(encoder, offset === 0 ? this.str : this.str.slice(offset))
|
||||
encoder.writeString(offset === 0 ? this.str : this.str.slice(offset))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -90,7 +106,7 @@ export class ContentString {
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentString}
|
||||
*/
|
||||
export const readContentString = decoder => new ContentString(decoding.readVarString(decoder))
|
||||
export const readContentString = decoder => new ContentString(decoder.readString())
|
||||
|
||||
@@ -7,15 +7,13 @@ import {
|
||||
readYXmlFragment,
|
||||
readYXmlHook,
|
||||
readYXmlText,
|
||||
StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @type {Array<function(decoding.Decoder):AbstractType<any>>}
|
||||
* @type {Array<function(AbstractUpdateDecoder):AbstractType<any>>}
|
||||
* @private
|
||||
*/
|
||||
export const typeRefs = [
|
||||
@@ -49,30 +47,35 @@ export class ContentType {
|
||||
*/
|
||||
this.type = type
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentType}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentType(this.type._copy())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentType}
|
||||
@@ -80,6 +83,7 @@ export class ContentType {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentType} right
|
||||
* @return {boolean}
|
||||
@@ -87,6 +91,7 @@ export class ContentType {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -94,6 +99,7 @@ export class ContentType {
|
||||
integrate (transaction, item) {
|
||||
this.type._integrate(transaction.doc, item)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
@@ -107,7 +113,7 @@ export class ContentType {
|
||||
// We try to merge all deleted items after each transaction,
|
||||
// but we have no knowledge about that this needs to be merged
|
||||
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
|
||||
transaction._mergeStructs.add(item.id)
|
||||
transaction._mergeStructs.push(item)
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
@@ -116,11 +122,12 @@ export class ContentType {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// same as above
|
||||
transaction._mergeStructs.add(item.id)
|
||||
transaction._mergeStructs.push(item)
|
||||
}
|
||||
})
|
||||
transaction.changed.delete(this.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
@@ -139,13 +146,15 @@ export class ContentType {
|
||||
})
|
||||
this.type._map = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
this.type._write(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -157,7 +166,7 @@ export class ContentType {
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentType}
|
||||
*/
|
||||
export const readContentType = decoder => new ContentType(typeRefs[decoding.readVarUint(decoder)](decoder))
|
||||
export const readContentType = decoder => new ContentType(typeRefs[decoder.readTypeRef()](decoder))
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
|
||||
import {
|
||||
AbstractStructRef,
|
||||
AbstractStruct,
|
||||
createID,
|
||||
addStruct,
|
||||
StructStore, Transaction, ID // eslint-disable-line
|
||||
AbstractUpdateEncoder, StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
export const structGCRefNumber = 0
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class GC extends AbstractStruct {
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor (id, length) {
|
||||
super(id, length)
|
||||
this.deleted = true
|
||||
get deleted () {
|
||||
return true
|
||||
}
|
||||
|
||||
delete () {}
|
||||
@@ -38,52 +28,31 @@ export class GC extends AbstractStruct {
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction) {
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.length -= offset
|
||||
}
|
||||
addStruct(transaction.doc.store, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoding.writeUint8(encoder, structGCRefNumber)
|
||||
encoding.writeVarUint(encoder, this.length - offset)
|
||||
encoder.writeInfo(structGCRefNumber)
|
||||
encoder.writeLen(this.length - offset)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class GCRef extends AbstractStructRef {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {ID} id
|
||||
* @param {number} info
|
||||
*/
|
||||
constructor (decoder, id, info) {
|
||||
super(id)
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.length = decoding.readVarUint(decoder)
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {GC}
|
||||
* @return {null | number}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
if (offset > 0) {
|
||||
// @ts-ignore
|
||||
this.id = createID(this.id.client, this.id.clock + offset)
|
||||
this.length -= offset
|
||||
}
|
||||
return new GC(
|
||||
this.id,
|
||||
this.length
|
||||
)
|
||||
getMissing (transaction, store) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
|
||||
import {
|
||||
readID,
|
||||
createID,
|
||||
writeID,
|
||||
GC,
|
||||
nextID,
|
||||
AbstractStructRef,
|
||||
getState,
|
||||
AbstractStruct,
|
||||
replaceStruct,
|
||||
addStruct,
|
||||
@@ -21,20 +17,20 @@ import {
|
||||
readContentAny,
|
||||
readContentString,
|
||||
readContentEmbed,
|
||||
readContentDoc,
|
||||
createID,
|
||||
readContentFormat,
|
||||
readContentType,
|
||||
addChangedTypeToTransaction,
|
||||
ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as maplib from 'lib0/map.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as binary from 'lib0/binary.js'
|
||||
|
||||
/**
|
||||
* @todo This should return several items
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {{item:Item, diff:number}}
|
||||
@@ -53,7 +49,7 @@ export const followRedone = (store, id) => {
|
||||
item = getItem(store, nextID)
|
||||
diff = nextID.clock - item.id.clock
|
||||
nextID = item.redone
|
||||
} while (nextID !== null)
|
||||
} while (nextID !== null && item instanceof Item)
|
||||
return {
|
||||
item, diff
|
||||
}
|
||||
@@ -66,11 +62,12 @@ export const followRedone = (store, id) => {
|
||||
* sending it to other peers
|
||||
*
|
||||
* @param {Item|null} item
|
||||
* @param {boolean} keep
|
||||
*/
|
||||
export const keepItem = item => {
|
||||
while (item !== null && !item.keep) {
|
||||
item.keep = true
|
||||
item = item.parent._item
|
||||
export const keepItem = (item, keep) => {
|
||||
while (item !== null && item.keep !== keep) {
|
||||
item.keep = keep
|
||||
item = /** @type {AbstractType<any>} */ (item.parent)._item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +82,12 @@ export const keepItem = item => {
|
||||
* @private
|
||||
*/
|
||||
export const splitItem = (transaction, leftItem, diff) => {
|
||||
const id = leftItem.id
|
||||
// create rightItem
|
||||
const { client, clock } = leftItem.id
|
||||
const rightItem = new Item(
|
||||
createID(id.client, id.clock + diff),
|
||||
createID(client, clock + diff),
|
||||
leftItem,
|
||||
createID(id.client, id.clock + diff - 1),
|
||||
createID(client, clock + diff - 1),
|
||||
leftItem.right,
|
||||
leftItem.rightOrigin,
|
||||
leftItem.parent,
|
||||
@@ -98,7 +95,7 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
leftItem.content.splice(diff)
|
||||
)
|
||||
if (leftItem.deleted) {
|
||||
rightItem.deleted = true
|
||||
rightItem.markDeleted()
|
||||
}
|
||||
if (leftItem.keep) {
|
||||
rightItem.keep = true
|
||||
@@ -113,10 +110,10 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
rightItem.right.left = rightItem
|
||||
}
|
||||
// right is more specific.
|
||||
transaction._mergeStructs.add(rightItem.id)
|
||||
transaction._mergeStructs.push(rightItem)
|
||||
// update parent._map
|
||||
if (rightItem.parentSub !== null && rightItem.right === null) {
|
||||
rightItem.parent._map.set(rightItem.parentSub, rightItem)
|
||||
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
|
||||
}
|
||||
leftItem.length = diff
|
||||
return rightItem
|
||||
@@ -134,10 +131,14 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
* @private
|
||||
*/
|
||||
export const redoItem = (transaction, item, redoitems) => {
|
||||
if (item.redone !== null) {
|
||||
return getItemCleanStart(transaction, item.redone)
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ownClientID = doc.clientID
|
||||
const redone = item.redone
|
||||
if (redone !== null) {
|
||||
return getItemCleanStart(transaction, redone)
|
||||
}
|
||||
let parentItem = item.parent._item
|
||||
let parentItem = /** @type {AbstractType<any>} */ (item.parent)._item
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
@@ -155,14 +156,14 @@ export const redoItem = (transaction, item, redoitems) => {
|
||||
left = item
|
||||
while (left.right !== null) {
|
||||
left = left.right
|
||||
if (left.id.client !== transaction.doc.clientID) {
|
||||
if (left.id.client !== ownClientID) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (left.right !== null) {
|
||||
left = /** @type {Item} */ (item.parent._map.get(item.parentSub))
|
||||
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
|
||||
}
|
||||
right = null
|
||||
}
|
||||
@@ -184,10 +185,10 @@ export const redoItem = (transaction, item, redoitems) => {
|
||||
*/
|
||||
let leftTrace = left
|
||||
// trace redone until parent matches
|
||||
while (leftTrace !== null && leftTrace.parent._item !== parentItem) {
|
||||
while (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item !== parentItem) {
|
||||
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
|
||||
}
|
||||
if (leftTrace !== null && leftTrace.parent._item === parentItem) {
|
||||
if (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item === parentItem) {
|
||||
left = leftTrace
|
||||
break
|
||||
}
|
||||
@@ -199,27 +200,29 @@ export const redoItem = (transaction, item, redoitems) => {
|
||||
*/
|
||||
let rightTrace = right
|
||||
// trace redone until parent matches
|
||||
while (rightTrace !== null && rightTrace.parent._item !== parentItem) {
|
||||
while (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item !== parentItem) {
|
||||
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
|
||||
}
|
||||
if (rightTrace !== null && rightTrace.parent._item === parentItem) {
|
||||
if (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item === parentItem) {
|
||||
right = rightTrace
|
||||
break
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
}
|
||||
const nextClock = getState(store, ownClientID)
|
||||
const nextId = createID(ownClientID, nextClock)
|
||||
const redoneItem = new Item(
|
||||
nextID(transaction),
|
||||
left, left === null ? null : left.lastId,
|
||||
right, right === null ? null : right.id,
|
||||
nextId,
|
||||
left, left && left.lastId,
|
||||
right, right && right.id,
|
||||
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
|
||||
item.parentSub,
|
||||
item.content.copy()
|
||||
)
|
||||
item.redone = redoneItem.id
|
||||
keepItem(redoneItem)
|
||||
redoneItem.integrate(transaction)
|
||||
item.redone = nextId
|
||||
keepItem(redoneItem, true)
|
||||
redoneItem.integrate(transaction, 0)
|
||||
return redoneItem
|
||||
}
|
||||
|
||||
@@ -233,7 +236,7 @@ export class Item extends AbstractStruct {
|
||||
* @param {ID | null} origin
|
||||
* @param {Item | null} right
|
||||
* @param {ID | null} rightOrigin
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {AbstractType<any>|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it.
|
||||
* @param {string | null} parentSub
|
||||
* @param {AbstractContent} content
|
||||
*/
|
||||
@@ -242,7 +245,6 @@ export class Item extends AbstractStruct {
|
||||
/**
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {ID | null}
|
||||
* @readonly
|
||||
*/
|
||||
this.origin = origin
|
||||
/**
|
||||
@@ -257,14 +259,11 @@ export class Item extends AbstractStruct {
|
||||
this.right = right
|
||||
/**
|
||||
* The item that was originally to the right of this item.
|
||||
* @readonly
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.rightOrigin = rightOrigin
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {AbstractType<any>}
|
||||
* @readonly
|
||||
* @type {AbstractType<any>|ID|null}
|
||||
*/
|
||||
this.parent = parent
|
||||
/**
|
||||
@@ -273,14 +272,8 @@ export class Item extends AbstractStruct {
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._map`.
|
||||
* @type {String | null}
|
||||
* @readonly
|
||||
*/
|
||||
this.parentSub = parentSub
|
||||
/**
|
||||
* Whether this item was deleted or not.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.deleted = false
|
||||
/**
|
||||
* If this type's effect is reundone this type refers to the type that undid
|
||||
* this operation.
|
||||
@@ -291,116 +284,240 @@ export class Item extends AbstractStruct {
|
||||
* @type {AbstractContent}
|
||||
*/
|
||||
this.content = content
|
||||
this.length = content.getLength()
|
||||
this.countable = content.isCountable()
|
||||
/**
|
||||
* If true, do not garbage collect this Item.
|
||||
* bit1: keep
|
||||
* bit2: countable
|
||||
* bit3: deleted
|
||||
* bit4: mark - mark node as fast-search-marker
|
||||
* @type {number} byte
|
||||
*/
|
||||
this.keep = false
|
||||
this.info = this.content.isCountable() ? binary.BIT2 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to mark the item as an indexed fast-search marker
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
set marker (isMarked) {
|
||||
if (((this.info & binary.BIT4) > 0) !== isMarked) {
|
||||
this.info ^= binary.BIT4
|
||||
}
|
||||
}
|
||||
|
||||
get marker () {
|
||||
return (this.info & binary.BIT4) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, do not garbage collect this Item.
|
||||
*/
|
||||
get keep () {
|
||||
return (this.info & binary.BIT1) > 0
|
||||
}
|
||||
|
||||
set keep (doKeep) {
|
||||
if (this.keep !== doKeep) {
|
||||
this.info ^= binary.BIT1
|
||||
}
|
||||
}
|
||||
|
||||
get countable () {
|
||||
return (this.info & binary.BIT2) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item was deleted or not.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
get deleted () {
|
||||
return (this.info & binary.BIT3) > 0
|
||||
}
|
||||
|
||||
set deleted (doDelete) {
|
||||
if (this.deleted !== doDelete) {
|
||||
this.info ^= binary.BIT3
|
||||
}
|
||||
}
|
||||
|
||||
markDeleted () {
|
||||
this.info |= binary.BIT3
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the creator clientID of the missing op or define missing items and return null.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @return {null | number}
|
||||
*/
|
||||
getMissing (transaction, store) {
|
||||
if (this.origin && this.origin.client !== this.id.client && this.origin.clock >= getState(store, this.origin.client)) {
|
||||
return this.origin.client
|
||||
}
|
||||
if (this.rightOrigin && this.rightOrigin.client !== this.id.client && this.rightOrigin.clock >= getState(store, this.rightOrigin.client)) {
|
||||
return this.rightOrigin.client
|
||||
}
|
||||
if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) {
|
||||
return this.parent.client
|
||||
}
|
||||
|
||||
// We have all missing ids, now find the items
|
||||
|
||||
if (this.origin) {
|
||||
this.left = getItemCleanEnd(transaction, store, this.origin)
|
||||
this.origin = this.left.lastId
|
||||
}
|
||||
if (this.rightOrigin) {
|
||||
this.right = getItemCleanStart(transaction, this.rightOrigin)
|
||||
this.rightOrigin = this.right.id
|
||||
}
|
||||
if ((this.left && this.left.constructor === GC) || (this.right && this.right.constructor === GC)) {
|
||||
this.parent = null
|
||||
}
|
||||
// only set parent if this shouldn't be garbage collected
|
||||
if (!this.parent) {
|
||||
if (this.left && this.left.constructor === Item) {
|
||||
this.parent = this.left.parent
|
||||
this.parentSub = this.left.parentSub
|
||||
}
|
||||
if (this.right && this.right.constructor === Item) {
|
||||
this.parent = this.right.parent
|
||||
this.parentSub = this.right.parentSub
|
||||
}
|
||||
} else if (this.parent.constructor === ID) {
|
||||
const parentItem = getItem(store, this.parent)
|
||||
if (parentItem.constructor === GC) {
|
||||
this.parent = null
|
||||
} else {
|
||||
this.parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @private
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction) {
|
||||
const store = transaction.doc.store
|
||||
const id = this.id
|
||||
const parent = this.parent
|
||||
const parentSub = this.parentSub
|
||||
const length = this.length
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (this.left !== null) {
|
||||
o = this.left.right
|
||||
} else if (parentSub !== null) {
|
||||
o = parent._map.get(parentSub) || null
|
||||
while (o !== null && o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
} else {
|
||||
o = parent._start
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1))
|
||||
this.origin = this.left.lastId
|
||||
this.content = this.content.splice(offset)
|
||||
this.length -= offset
|
||||
}
|
||||
// TODO: use something like DeleteSet here (a tree implementation would be best)
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const conflictingItems = new Set()
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this.right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (compareIDs(this.origin, o.origin)) {
|
||||
// case 1
|
||||
if (o.id.client < id.client) {
|
||||
this.left = o
|
||||
conflictingItems.clear()
|
||||
|
||||
if (this.parent) {
|
||||
if ((!this.left && (!this.right || this.right.left !== null)) || (this.left && this.left.right !== this.right)) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left = this.left
|
||||
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (left !== null) {
|
||||
o = left.right
|
||||
} else if (this.parentSub !== null) {
|
||||
o = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
|
||||
while (o !== null && o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
} else {
|
||||
o = /** @type {AbstractType<any>} */ (this.parent)._start
|
||||
}
|
||||
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
|
||||
// case 2
|
||||
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
|
||||
this.left = o
|
||||
conflictingItems.clear()
|
||||
// TODO: use something like DeleteSet here (a tree implementation would be best)
|
||||
// @todo use global set definitions
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const conflictingItems = new Set()
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this.right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (compareIDs(this.origin, o.origin)) {
|
||||
// case 1
|
||||
if (o.id.client < this.id.client) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
} else if (compareIDs(this.rightOrigin, o.rightOrigin)) {
|
||||
// this and o are conflicting and point to the same integration points. The id decides which item comes first.
|
||||
// Since this is to the left of o, we can break here
|
||||
break
|
||||
} // else, o might be integrated before an item that this conflicts with. If so, we will find it in the next iterations
|
||||
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) { // use getItem instead of getItemCleanEnd because we don't want / need to split items.
|
||||
// case 2
|
||||
if (!conflictingItems.has(getItem(transaction.doc.store, o.origin))) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
o = o.right
|
||||
}
|
||||
} else {
|
||||
break
|
||||
this.left = left
|
||||
}
|
||||
o = o.right
|
||||
}
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
if (this.left !== null) {
|
||||
const right = this.left.right
|
||||
this.right = right
|
||||
this.left.right = this
|
||||
} else {
|
||||
let r
|
||||
if (parentSub !== null) {
|
||||
r = parent._map.get(parentSub) || null
|
||||
while (r !== null && r.left !== null) {
|
||||
r = r.left
|
||||
}
|
||||
} else {
|
||||
r = parent._start
|
||||
parent._start = this
|
||||
}
|
||||
this.right = r
|
||||
}
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
} else if (parentSub !== null) {
|
||||
// set as current parent value if right === null and this is parentSub
|
||||
parent._map.set(parentSub, this)
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
if (this.left !== null) {
|
||||
// this is the current attribute value of parent. delete right
|
||||
this.left.delete(transaction)
|
||||
const right = this.left.right
|
||||
this.right = right
|
||||
this.left.right = this
|
||||
} else {
|
||||
let r
|
||||
if (this.parentSub !== null) {
|
||||
r = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
|
||||
while (r !== null && r.left !== null) {
|
||||
r = r.left
|
||||
}
|
||||
} else {
|
||||
r = /** @type {AbstractType<any>} */ (this.parent)._start
|
||||
;/** @type {AbstractType<any>} */ (this.parent)._start = this
|
||||
}
|
||||
this.right = r
|
||||
}
|
||||
}
|
||||
// adjust length of parent
|
||||
if (parentSub === null && this.countable && !this.deleted) {
|
||||
parent._length += length
|
||||
}
|
||||
addStruct(store, this)
|
||||
this.content.integrate(transaction, this)
|
||||
// add parent to transaction.changed
|
||||
addChangedTypeToTransaction(transaction, parent, parentSub)
|
||||
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
|
||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||
this.delete(transaction)
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
} else if (this.parentSub !== null) {
|
||||
// set as current parent value if right === null and this is parentSub
|
||||
/** @type {AbstractType<any>} */ (this.parent)._map.set(this.parentSub, this)
|
||||
if (this.left !== null) {
|
||||
// this is the current attribute value of parent. delete right
|
||||
this.left.delete(transaction)
|
||||
}
|
||||
}
|
||||
// adjust length of parent
|
||||
if (this.parentSub === null && this.countable && !this.deleted) {
|
||||
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
|
||||
}
|
||||
addStruct(transaction.doc.store, this)
|
||||
this.content.integrate(transaction, this)
|
||||
// add parent to transaction.changed
|
||||
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub)
|
||||
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
|
||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||
this.delete(transaction)
|
||||
}
|
||||
} else {
|
||||
// parent is not defined. Integrate GC struct instead
|
||||
new GC(this.id, this.length).integrate(transaction, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next non-deleted item
|
||||
* @private
|
||||
*/
|
||||
get next () {
|
||||
let n = this.right
|
||||
@@ -412,7 +529,6 @@ export class Item extends AbstractStruct {
|
||||
|
||||
/**
|
||||
* Returns the previous non-deleted item
|
||||
* @private
|
||||
*/
|
||||
get prev () {
|
||||
let n = this.left
|
||||
@@ -426,8 +542,10 @@ export class Item extends AbstractStruct {
|
||||
* Computes the last content address of this Item.
|
||||
*/
|
||||
get lastId () {
|
||||
return createID(this.id.client, this.id.clock + this.length - 1)
|
||||
// allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible
|
||||
return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to merge two items
|
||||
*
|
||||
@@ -467,14 +585,14 @@ export class Item extends AbstractStruct {
|
||||
*/
|
||||
delete (transaction) {
|
||||
if (!this.deleted) {
|
||||
const parent = this.parent
|
||||
const parent = /** @type {AbstractType<any>} */ (this.parent)
|
||||
// adjust the length of parent
|
||||
if (this.countable && this.parentSub === null) {
|
||||
parent._length -= this.length
|
||||
}
|
||||
this.deleted = true
|
||||
addToDeleteSet(transaction.deleteSet, this.id, this.length)
|
||||
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
|
||||
this.markDeleted()
|
||||
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
|
||||
addChangedTypeToTransaction(transaction, parent, this.parentSub)
|
||||
this.content.delete(transaction)
|
||||
}
|
||||
}
|
||||
@@ -482,8 +600,6 @@ export class Item extends AbstractStruct {
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
gc (store, parentGCd) {
|
||||
if (!this.deleted) {
|
||||
@@ -503,10 +619,8 @@ export class Item extends AbstractStruct {
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin
|
||||
@@ -516,27 +630,28 @@ export class Item extends AbstractStruct {
|
||||
(origin === null ? 0 : binary.BIT8) | // origin is defined
|
||||
(rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined
|
||||
(parentSub === null ? 0 : binary.BIT6) // parentSub is non-null
|
||||
encoding.writeUint8(encoder, info)
|
||||
encoder.writeInfo(info)
|
||||
if (origin !== null) {
|
||||
writeID(encoder, origin)
|
||||
encoder.writeLeftID(origin)
|
||||
}
|
||||
if (rightOrigin !== null) {
|
||||
writeID(encoder, rightOrigin)
|
||||
encoder.writeRightID(rightOrigin)
|
||||
}
|
||||
if (origin === null && rightOrigin === null) {
|
||||
const parent = this.parent
|
||||
if (parent._item === null) {
|
||||
const parent = /** @type {AbstractType<any>} */ (this.parent)
|
||||
const parentItem = parent._item
|
||||
if (parentItem === null) {
|
||||
// parent type on y._map
|
||||
// find the correct key
|
||||
const ykey = findRootTypeKey(parent)
|
||||
encoding.writeVarUint(encoder, 1) // write parentYKey
|
||||
encoding.writeVarString(encoder, ykey)
|
||||
encoder.writeParentInfo(true) // write parentYKey
|
||||
encoder.writeString(ykey)
|
||||
} else {
|
||||
encoding.writeVarUint(encoder, 0) // write parent id
|
||||
writeID(encoder, parent._item.id)
|
||||
encoder.writeParentInfo(false) // write parent id
|
||||
encoder.writeLeftID(parentItem.id)
|
||||
}
|
||||
if (parentSub !== null) {
|
||||
encoding.writeVarString(encoder, parentSub)
|
||||
encoder.writeString(parentSub)
|
||||
}
|
||||
}
|
||||
this.content.write(encoder, offset)
|
||||
@@ -544,26 +659,27 @@ export class Item extends AbstractStruct {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @param {number} info
|
||||
*/
|
||||
const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
|
||||
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
|
||||
|
||||
/**
|
||||
* A lookup map for reading Item content.
|
||||
*
|
||||
* @type {Array<function(decoding.Decoder):AbstractContent>}
|
||||
* @type {Array<function(AbstractUpdateDecoder):AbstractContent>}
|
||||
*/
|
||||
export const contentRefs = [
|
||||
() => { throw error.unexpectedCase() }, // GC is not ItemContent
|
||||
readContentDeleted,
|
||||
readContentJSON,
|
||||
readContentBinary,
|
||||
readContentString,
|
||||
readContentEmbed,
|
||||
readContentFormat,
|
||||
readContentType,
|
||||
readContentAny
|
||||
readContentDeleted, // 1
|
||||
readContentJSON, // 2
|
||||
readContentBinary, // 3
|
||||
readContentString, // 4
|
||||
readContentEmbed, // 5
|
||||
readContentFormat, // 6
|
||||
readContentType, // 7
|
||||
readContentAny, // 8
|
||||
readContentDoc // 9
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -576,12 +692,14 @@ export class AbstractContent {
|
||||
getLength () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return false if this Item is some kind of meta information
|
||||
* (e.g. format information).
|
||||
@@ -594,12 +712,14 @@ export class AbstractContent {
|
||||
isCountable () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {AbstractContent}
|
||||
*/
|
||||
copy () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {AbstractContent}
|
||||
@@ -607,6 +727,7 @@ export class AbstractContent {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractContent} right
|
||||
* @return {boolean}
|
||||
@@ -614,6 +735,7 @@ export class AbstractContent {
|
||||
mergeWith (right) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -621,25 +743,29 @@ export class AbstractContent {
|
||||
integrate (transaction, item) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -647,124 +773,3 @@ export class AbstractContent {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ItemRef extends AbstractStructRef {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {ID} id
|
||||
* @param {number} info
|
||||
*/
|
||||
constructor (decoder, id, info) {
|
||||
super(id)
|
||||
/**
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.left = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
|
||||
/**
|
||||
* The item that was originally to the right of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.right = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
|
||||
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
|
||||
/**
|
||||
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
* and we read the next string as parentYKey.
|
||||
* It indicates how we store/retrieve parent from `y.share`
|
||||
* @type {string|null}
|
||||
*/
|
||||
this.parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : null
|
||||
/**
|
||||
* If the parent refers to this item with some kind of key (e.g. YMap, the
|
||||
* key is specified here. The key is then used to refer to the list in which
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._map`.
|
||||
* @type {String | null}
|
||||
*/
|
||||
this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
|
||||
const missing = this._missing
|
||||
if (this.left !== null) {
|
||||
missing.push(this.left)
|
||||
}
|
||||
if (this.right !== null) {
|
||||
missing.push(this.right)
|
||||
}
|
||||
if (this.parent !== null) {
|
||||
missing.push(this.parent)
|
||||
}
|
||||
/**
|
||||
* @type {AbstractContent}
|
||||
*/
|
||||
this.content = readItemContent(decoder, info)
|
||||
this.length = this.content.getLength()
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {Item|GC}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
if (offset > 0) {
|
||||
/**
|
||||
* @type {ID}
|
||||
*/
|
||||
const id = this.id
|
||||
this.id = createID(id.client, id.clock + offset)
|
||||
this.left = createID(this.id.client, this.id.clock - 1)
|
||||
this.content = this.content.splice(offset)
|
||||
this.length -= offset
|
||||
}
|
||||
|
||||
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
|
||||
const right = this.right === null ? null : getItemCleanStart(transaction, this.right)
|
||||
let parent = null
|
||||
let parentSub = this.parentSub
|
||||
if (this.parent !== null) {
|
||||
const parentItem = getItem(store, this.parent)
|
||||
// Edge case: toStruct is called with an offset > 0. In this case left is defined.
|
||||
// Depending in which order structs arrive, left may be GC'd and the parent not
|
||||
// deleted. This is why we check if left is GC'd. Strictly we don't have
|
||||
// to check if right is GC'd, but we will in case we run into future issues
|
||||
if (!parentItem.deleted && (left === null || left.constructor !== GC) && (right === null || right.constructor !== GC)) {
|
||||
parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
} else if (this.parentYKey !== null) {
|
||||
parent = transaction.doc.get(this.parentYKey)
|
||||
} else if (left !== null) {
|
||||
if (left.constructor !== GC) {
|
||||
parent = left.parent
|
||||
parentSub = left.parentSub
|
||||
}
|
||||
} else if (right !== null) {
|
||||
if (right.constructor !== GC) {
|
||||
parent = right.parent
|
||||
parentSub = right.parentSub
|
||||
}
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
|
||||
return parent === null
|
||||
? new GC(this.id, this.length)
|
||||
: new Item(
|
||||
this.id,
|
||||
left,
|
||||
this.left,
|
||||
right,
|
||||
this.right,
|
||||
parent,
|
||||
parentSub,
|
||||
this.content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,230 @@ import {
|
||||
callEventHandlerListeners,
|
||||
addEventHandlerListener,
|
||||
createEventHandler,
|
||||
nextID,
|
||||
getState,
|
||||
isVisible,
|
||||
ContentType,
|
||||
createID,
|
||||
ContentAny,
|
||||
ContentBinary,
|
||||
createID,
|
||||
getItemCleanStart,
|
||||
Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
|
||||
ContentDoc, YText, YArray, AbstractUpdateEncoder, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as iterator from 'lib0/iterator.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
const maxSearchMarker = 80
|
||||
|
||||
/**
|
||||
* A unique timestamp that identifies each marker.
|
||||
*
|
||||
* Time is relative,.. this is more like an ever-increasing clock.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
let globalSearchMarkerTimestamp = 0
|
||||
|
||||
export class ArraySearchMarker {
|
||||
/**
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor (p, index) {
|
||||
p.marker = true
|
||||
this.p = p
|
||||
this.index = index
|
||||
this.timestamp = globalSearchMarkerTimestamp++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArraySearchMarker} marker
|
||||
*/
|
||||
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }
|
||||
|
||||
/**
|
||||
* This is rather complex so this function is the only thing that should overwrite a marker
|
||||
*
|
||||
* @param {ArraySearchMarker} marker
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
const overwriteMarker = (marker, p, index) => {
|
||||
marker.p.marker = false
|
||||
marker.p = p
|
||||
p.marker = true
|
||||
marker.index = index
|
||||
marker.timestamp = globalSearchMarkerTimestamp++
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<ArraySearchMarker>} searchMarker
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
const markPosition = (searchMarker, p, index) => {
|
||||
if (searchMarker.length >= maxSearchMarker) {
|
||||
// override oldest marker (we don't want to create more objects)
|
||||
const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
|
||||
overwriteMarker(marker, p, index)
|
||||
return marker
|
||||
} else {
|
||||
// create new marker
|
||||
const pm = new ArraySearchMarker(p, index)
|
||||
searchMarker.push(pm)
|
||||
return pm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search marker help us to find positions in the associative array faster.
|
||||
*
|
||||
* They speed up the process of finding a position without much bookkeeping.
|
||||
*
|
||||
* A maximum of `maxSearchMarker` objects are created.
|
||||
*
|
||||
* This function always returns a refreshed marker (updated timestamp)
|
||||
*
|
||||
* @param {AbstractType<any>} yarray
|
||||
* @param {number} index
|
||||
*/
|
||||
export const findMarker = (yarray, index) => {
|
||||
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
|
||||
return null
|
||||
}
|
||||
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
|
||||
let p = yarray._start
|
||||
let pindex = 0
|
||||
if (marker !== null) {
|
||||
p = marker.p
|
||||
pindex = marker.index
|
||||
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
|
||||
}
|
||||
// iterate to right if possible
|
||||
while (p.right !== null && pindex < index) {
|
||||
if (!p.deleted && p.countable) {
|
||||
if (index < pindex + p.length) {
|
||||
break
|
||||
}
|
||||
pindex += p.length
|
||||
}
|
||||
p = p.right
|
||||
}
|
||||
// iterate to left if necessary (might be that pindex > index)
|
||||
while (p.left !== null && pindex > index) {
|
||||
p = p.left
|
||||
if (!p.deleted && p.countable) {
|
||||
pindex -= p.length
|
||||
}
|
||||
}
|
||||
// we want to make sure that p can't be merged with left, because that would screw up everything
|
||||
// in that cas just return what we have (it is most likely the best marker anyway)
|
||||
// iterate to left until p can't be merged with left
|
||||
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
|
||||
p = p.left
|
||||
if (!p.deleted && p.countable) {
|
||||
pindex -= p.length
|
||||
}
|
||||
}
|
||||
|
||||
// @todo remove!
|
||||
// assure position
|
||||
// {
|
||||
// let start = yarray._start
|
||||
// let pos = 0
|
||||
// while (start !== p) {
|
||||
// if (!start.deleted && start.countable) {
|
||||
// pos += start.length
|
||||
// }
|
||||
// start = /** @type {Item} */ (start.right)
|
||||
// }
|
||||
// if (pos !== pindex) {
|
||||
// debugger
|
||||
// throw new Error('Gotcha position fail!')
|
||||
// }
|
||||
// }
|
||||
// if (marker) {
|
||||
// if (window.lengthes == null) {
|
||||
// window.lengthes = []
|
||||
// window.getLengthes = () => window.lengthes.sort((a, b) => a - b)
|
||||
// }
|
||||
// window.lengthes.push(marker.index - pindex)
|
||||
// console.log('distance', marker.index - pindex, 'len', p && p.parent.length)
|
||||
// }
|
||||
if (marker !== null && math.abs(marker.index - pindex) < /** @type {YText|YArray<any>} */ (p.parent).length / maxSearchMarker) {
|
||||
// adjust existing marker
|
||||
overwriteMarker(marker, p, pindex)
|
||||
return marker
|
||||
} else {
|
||||
// create new marker
|
||||
return markPosition(yarray._searchMarker, p, pindex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update markers when a change happened.
|
||||
*
|
||||
* This should be called before doing a deletion!
|
||||
*
|
||||
* @param {Array<ArraySearchMarker>} searchMarker
|
||||
* @param {number} index
|
||||
* @param {number} len If insertion, len is positive. If deletion, len is negative.
|
||||
*/
|
||||
export const updateMarkerChanges = (searchMarker, index, len) => {
|
||||
for (let i = searchMarker.length - 1; i >= 0; i--) {
|
||||
const m = searchMarker[i]
|
||||
if (len > 0) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let p = m.p
|
||||
p.marker = false
|
||||
// Ideally we just want to do a simple position comparison, but this will only work if
|
||||
// search markers don't point to deleted items for formats.
|
||||
// Iterate marker to prev undeleted countable position so we know what to do when updating a position
|
||||
while (p && (p.deleted || !p.countable)) {
|
||||
p = p.left
|
||||
if (p && !p.deleted && p.countable) {
|
||||
// adjust position. the loop should break now
|
||||
m.index -= p.length
|
||||
}
|
||||
}
|
||||
if (p === null || p.marker === true) {
|
||||
// remove search marker if updated position is null or if position is already marked
|
||||
searchMarker.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
m.p = p
|
||||
p.marker = true
|
||||
}
|
||||
if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
|
||||
m.index = math.max(index, m.index + len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate all (list) children of a type and return them as an Array.
|
||||
*
|
||||
* @param {AbstractType<any>} t
|
||||
* @return {Array<Item>}
|
||||
*/
|
||||
export const getTypeChildren = t => {
|
||||
let s = t._start
|
||||
const arr = []
|
||||
while (s) {
|
||||
arr.push(s)
|
||||
s = s.right
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* Call event listeners with an event. This will also add an event to all
|
||||
* parents (for `.observeDeep` handlers).
|
||||
* @private
|
||||
*
|
||||
* @template EventType
|
||||
* @param {AbstractType<EventType>} type
|
||||
@@ -30,7 +235,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
* @param {EventType} event
|
||||
*/
|
||||
export const callTypeObservers = (type, transaction, event) => {
|
||||
callEventHandlerListeners(type._eH, event, transaction)
|
||||
const changedType = type
|
||||
const changedParentTypes = transaction.changedParentTypes
|
||||
while (true) {
|
||||
// @ts-ignore
|
||||
@@ -38,8 +243,9 @@ export const callTypeObservers = (type, transaction, event) => {
|
||||
if (type._item === null) {
|
||||
break
|
||||
}
|
||||
type = type._item.parent
|
||||
type = /** @type {AbstractType<any>} */ (type._item.parent)
|
||||
}
|
||||
callEventHandlerListeners(changedType._eH, event, transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,17 +259,14 @@ export class AbstractType {
|
||||
*/
|
||||
this._item = null
|
||||
/**
|
||||
* @private
|
||||
* @type {Map<string,Item>}
|
||||
*/
|
||||
this._map = new Map()
|
||||
/**
|
||||
* @private
|
||||
* @type {Item|null}
|
||||
*/
|
||||
this._start = null
|
||||
/**
|
||||
* @private
|
||||
* @type {Doc|null}
|
||||
*/
|
||||
this.doc = null
|
||||
@@ -78,6 +281,17 @@ export class AbstractType {
|
||||
* @type {EventHandler<Array<YEvent>,Transaction>}
|
||||
*/
|
||||
this._dEH = createEventHandler()
|
||||
/**
|
||||
* @type {null | Array<ArraySearchMarker>}
|
||||
*/
|
||||
this._searchMarker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {AbstractType<any>|null}
|
||||
*/
|
||||
get parent () {
|
||||
return this._item ? /** @type {AbstractType<any>} */ (this._item.parent) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +303,6 @@ export class AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item|null} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
this.doc = y
|
||||
@@ -98,15 +311,20 @@ export class AbstractType {
|
||||
|
||||
/**
|
||||
* @return {AbstractType<EventType>}
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @private
|
||||
* @return {AbstractType<EventType>}
|
||||
*/
|
||||
clone () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) { }
|
||||
|
||||
@@ -127,10 +345,12 @@ export class AbstractType {
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) { /* skip if no type is specified */ }
|
||||
_callObserver (transaction, parentSubs) {
|
||||
if (!transaction.local && this._searchMarker) {
|
||||
this._searchMarker.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe all events that are created on this type.
|
||||
@@ -170,11 +390,48 @@ export class AbstractType {
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @return {Object | Array | number | string}
|
||||
* @return {any}
|
||||
*/
|
||||
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>}
|
||||
@@ -346,7 +603,13 @@ export const typeListForEachSnapshot = (type, f, snapshot) => {
|
||||
* @function
|
||||
*/
|
||||
export const typeListGet = (type, index) => {
|
||||
for (let n = type._start; n !== null; n = n.right) {
|
||||
const marker = findMarker(type, index)
|
||||
let n = type._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
}
|
||||
for (; n !== null; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
if (index < n.length) {
|
||||
return n.content.getContent()[index]
|
||||
@@ -367,15 +630,18 @@ export const typeListGet = (type, index) => {
|
||||
*/
|
||||
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
|
||||
let left = referenceItem
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
const store = doc.store
|
||||
const right = referenceItem === null ? parent._start : referenceItem.right
|
||||
/**
|
||||
* @type {Array<Object|Array|number>}
|
||||
* @type {Array<Object|Array<any>|number>}
|
||||
*/
|
||||
let jsonContent = []
|
||||
const packJsonContent = () => {
|
||||
if (jsonContent.length > 0) {
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentAny(jsonContent))
|
||||
left.integrate(transaction)
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
|
||||
left.integrate(transaction, 0)
|
||||
jsonContent = []
|
||||
}
|
||||
}
|
||||
@@ -393,13 +659,17 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
|
||||
switch (c.constructor) {
|
||||
case Uint8Array:
|
||||
case ArrayBuffer:
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
|
||||
left.integrate(transaction)
|
||||
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(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentType(c))
|
||||
left.integrate(transaction)
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
|
||||
left.integrate(transaction, 0)
|
||||
} else {
|
||||
throw new Error('Unexpected content type in insert operation')
|
||||
}
|
||||
@@ -420,9 +690,24 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
|
||||
*/
|
||||
export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
if (index === 0) {
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, index, content.length)
|
||||
}
|
||||
return typeListInsertGenericsAfter(transaction, parent, null, content)
|
||||
}
|
||||
const startIndex = index
|
||||
const marker = findMarker(parent, index)
|
||||
let n = parent._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
// we need to iterate one to the left so that the algorithm works
|
||||
if (index === 0) {
|
||||
// @todo refactor this as it actually doesn't consider formats
|
||||
n = n.prev // important! get the left undeleted item so that we can actually decrease index
|
||||
index += (n && n.countable && !n.deleted) ? n.length : 0
|
||||
}
|
||||
}
|
||||
for (; n !== null; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
if (index <= n.length) {
|
||||
@@ -435,6 +720,9 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
index -= n.length
|
||||
}
|
||||
}
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, startIndex, content.length)
|
||||
}
|
||||
return typeListInsertGenericsAfter(transaction, parent, n, content)
|
||||
}
|
||||
|
||||
@@ -449,7 +737,14 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
*/
|
||||
export const typeListDelete = (transaction, parent, index, length) => {
|
||||
if (length === 0) { return }
|
||||
const startIndex = index
|
||||
const startLength = length
|
||||
const marker = findMarker(parent, index)
|
||||
let n = parent._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
}
|
||||
// compute the first item to be deleted
|
||||
for (; n !== null && index > 0; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
@@ -473,6 +768,9 @@ export const typeListDelete = (transaction, parent, index, length) => {
|
||||
if (length > 0) {
|
||||
throw error.create('array length exceeded')
|
||||
}
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -501,6 +799,8 @@ export const typeMapDelete = (transaction, parent, key) => {
|
||||
*/
|
||||
export const typeMapSet = (transaction, parent, key, value) => {
|
||||
const left = parent._map.get(key) || null
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
let content
|
||||
if (value == null) {
|
||||
content = new ContentAny([value])
|
||||
@@ -514,7 +814,10 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
content = new ContentAny([value])
|
||||
break
|
||||
case Uint8Array:
|
||||
content = new ContentBinary(value)
|
||||
content = new ContentBinary(/** @type {Uint8Array} */ (value))
|
||||
break
|
||||
case Doc:
|
||||
content = new ContentDoc(/** @type {Doc} */ (value))
|
||||
break
|
||||
default:
|
||||
if (value instanceof AbstractType) {
|
||||
@@ -524,7 +827,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction)
|
||||
new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,12 +854,12 @@ export const typeMapGetAll = (parent) => {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let res = {}
|
||||
for (const [key, value] of parent._map) {
|
||||
const res = {}
|
||||
parent._map.forEach((value, key) => {
|
||||
if (!value.deleted) {
|
||||
res[key] = value.content.getContent()[value.length - 1]
|
||||
}
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
YArrayRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Doc, Transaction, Item // eslint-disable-line
|
||||
ArraySearchMarker, AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import { typeListSlice } from './AbstractType.js'
|
||||
|
||||
/**
|
||||
* Event that describes the changes on a YArray
|
||||
@@ -40,7 +38,7 @@ export class YArrayEvent extends YEvent {
|
||||
* A shared Array implementation.
|
||||
* @template T
|
||||
* @extends AbstractType<YArrayEvent<T>>
|
||||
* @implements {IterableIterator<T>}
|
||||
* @implements {Iterable<T>}
|
||||
*/
|
||||
export class YArray extends AbstractType {
|
||||
constructor () {
|
||||
@@ -50,7 +48,24 @@ export class YArray extends AbstractType {
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = []
|
||||
/**
|
||||
* @type {Array<ArraySearchMarker>}
|
||||
*/
|
||||
this._searchMarker = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a new YArray containing the specified items.
|
||||
* @template T
|
||||
* @param {Array<T>} items
|
||||
* @return {YArray<T>}
|
||||
*/
|
||||
static from (items) {
|
||||
const a = new YArray()
|
||||
a.push(items)
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
@@ -60,12 +75,10 @@ export class YArray extends AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array} */ (this._prelimContent))
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
@@ -73,18 +86,29 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates YArrayEvent and calls observers.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
super._callObserver(transaction, parentSubs)
|
||||
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
|
||||
}
|
||||
|
||||
@@ -110,7 +134,7 @@ export class YArray extends AbstractType {
|
||||
typeListInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array} */ (this._prelimContent).splice(index, 0, ...content)
|
||||
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +147,15 @@ export class YArray extends AbstractType {
|
||||
this.insert(this.length, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preppends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to preppend.
|
||||
*/
|
||||
unshift (content) {
|
||||
this.insert(0, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
@@ -135,7 +168,7 @@ export class YArray extends AbstractType {
|
||||
typeListDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array} */ (this._prelimContent).splice(index, length)
|
||||
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,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.
|
||||
*
|
||||
@@ -197,16 +241,15 @@ export class YArray extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @private
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YArrayRefID)
|
||||
encoder.writeTypeRef(YArrayRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
YMapRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Doc, Transaction, Item // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
import * as iterator from 'lib0/iterator.js'
|
||||
|
||||
/**
|
||||
@@ -42,17 +40,28 @@ export class YMapEvent extends YEvent {
|
||||
* A shared Map implementation.
|
||||
*
|
||||
* @extends AbstractType<YMapEvent<T>>
|
||||
* @implements {IterableIterator}
|
||||
* @implements {Iterable<T>}
|
||||
*/
|
||||
export class YMap extends AbstractType {
|
||||
constructor () {
|
||||
/**
|
||||
*
|
||||
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
|
||||
*/
|
||||
constructor (entries) {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,any>?}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = new Map()
|
||||
this._prelimContent = null
|
||||
|
||||
if (entries === undefined) {
|
||||
this._prelimContent = new Map()
|
||||
} else {
|
||||
this._prelimContent = new Map(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
@@ -62,14 +71,12 @@ export class YMap extends AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
for (let [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
|
||||
;/** @type {Map<string, any>} */ (this._prelimContent).forEach((value, key) => {
|
||||
this.set(key, value)
|
||||
}
|
||||
})
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
@@ -77,13 +84,22 @@ 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.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs))
|
||||
@@ -99,15 +115,24 @@ export class YMap extends AbstractType {
|
||||
* @type {Object<string,T>}
|
||||
*/
|
||||
const map = {}
|
||||
for (let [key, item] of this._map) {
|
||||
this._map.forEach((item, key) => {
|
||||
if (!item.deleted) {
|
||||
const v = item.content.getContent()[item.length - 1]
|
||||
map[key] = v instanceof AbstractType ? v.toJSON() : v
|
||||
}
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the YMap (count of key/value pairs)
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
get size () {
|
||||
return [...createMapIterator(this._map)].length
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the keys for each element in the YMap Type.
|
||||
*
|
||||
@@ -118,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])
|
||||
@@ -136,7 +161,7 @@ export class YMap extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provided function on once on overy key-value pair.
|
||||
* Executes a provided function on once on every key-value pair.
|
||||
*
|
||||
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
|
||||
*/
|
||||
@@ -145,11 +170,11 @@ export class YMap extends AbstractType {
|
||||
* @type {Object<string,T>}
|
||||
*/
|
||||
const map = {}
|
||||
for (let [key, item] of this._map) {
|
||||
this._map.forEach((item, key) => {
|
||||
if (!item.deleted) {
|
||||
f(item.content.getContent()[item.length - 1], key, this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -213,17 +238,15 @@ export class YMap extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
* @private
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YMapRefID)
|
||||
encoder.writeTypeRef(YMapRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,9 @@ import {
|
||||
typeMapGetAll,
|
||||
typeListForEach,
|
||||
YXmlElementRefID,
|
||||
Snapshot, Doc, Item // eslint-disable-line
|
||||
YXmlText, ContentType, AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Snapshot, Doc, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* An YXmlElement imitates the behavior of a
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
|
||||
@@ -27,11 +24,26 @@ export class YXmlElement extends YXmlFragment {
|
||||
this.nodeName = nodeName
|
||||
/**
|
||||
* @type {Map<string, any>|null}
|
||||
* @private
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -41,7 +53,6 @@ export class YXmlElement extends YXmlFragment {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
@@ -55,12 +66,25 @@ export class YXmlElement extends YXmlFragment {
|
||||
* Creates an Item with the same effect as this Item (without position effect)
|
||||
*
|
||||
* @return {YXmlElement}
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
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
|
||||
@@ -74,7 +98,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
const attrs = this.getAttributes()
|
||||
const stringBuilder = []
|
||||
const keys = []
|
||||
for (let key in attrs) {
|
||||
for (const key in attrs) {
|
||||
keys.push(key)
|
||||
}
|
||||
keys.sort()
|
||||
@@ -140,7 +164,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
* Returns all attribute name/value pairs in a JSON Object.
|
||||
*
|
||||
* @param {Snapshot} [snapshot]
|
||||
* @return {Object} A JSON Object that describes the attributes.
|
||||
* @return {Object<string, any>} A JSON Object that describes the attributes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@@ -165,8 +189,8 @@ export class YXmlElement extends YXmlFragment {
|
||||
*/
|
||||
toDOM (_document = document, hooks = {}, binding) {
|
||||
const dom = _document.createElement(this.nodeName)
|
||||
let attrs = this.getAttributes()
|
||||
for (let key in attrs) {
|
||||
const attrs = this.getAttributes()
|
||||
for (const key in attrs) {
|
||||
dom.setAttribute(key, attrs[key])
|
||||
}
|
||||
typeListForEach(this, yxml => {
|
||||
@@ -184,20 +208,18 @@ export class YXmlElement extends YXmlFragment {
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @private
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YXmlElementRefID)
|
||||
encoding.writeVarString(encoder, this.nodeName)
|
||||
encoder.writeTypeRef(YXmlElementRefID)
|
||||
encoder.writeKey(this.nodeName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlElement}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))
|
||||
export const readYXmlElement = decoder => new YXmlElement(decoder.readKey())
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,16 +9,18 @@ import {
|
||||
typeListMap,
|
||||
typeListForEach,
|
||||
typeListInsertGenerics,
|
||||
typeListInsertGenericsAfter,
|
||||
typeListDelete,
|
||||
typeListToArray,
|
||||
YXmlFragmentRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
typeListGet,
|
||||
typeListSlice,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* Define the elements to which a set of CSS queries apply.
|
||||
@@ -48,7 +50,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
* Can be created with {@link YXmlFragment#createTreeWalker}
|
||||
*
|
||||
* @public
|
||||
* @implements {IterableIterator}
|
||||
* @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
|
||||
*/
|
||||
export class YXmlTreeWalker {
|
||||
/**
|
||||
@@ -68,6 +70,7 @@ export class YXmlTreeWalker {
|
||||
[Symbol.iterator] () {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next node.
|
||||
*
|
||||
@@ -80,10 +83,10 @@ export class YXmlTreeWalker {
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let n = this._currentNode
|
||||
let type = /** @type {ContentType} */ (n.content).type
|
||||
let type = /** @type {any} */ (n.content).type
|
||||
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
|
||||
do {
|
||||
type = /** @type {ContentType} */ (n.content).type
|
||||
type = /** @type {any} */ (n.content).type
|
||||
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
|
||||
// walk down in the tree
|
||||
n = type._start
|
||||
@@ -96,7 +99,7 @@ export class YXmlTreeWalker {
|
||||
} else if (n.parent === this._root) {
|
||||
n = null
|
||||
} else {
|
||||
n = n.parent._item
|
||||
n = /** @type {AbstractType<any>} */ (n.parent)._item
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +129,18 @@ export class YXmlFragment extends AbstractType {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
* @private
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -139,11 +150,10 @@ export class YXmlFragment extends AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array} */ (this._prelimContent))
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
@@ -151,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
|
||||
}
|
||||
@@ -222,7 +242,6 @@ export class YXmlFragment extends AbstractType {
|
||||
|
||||
/**
|
||||
* Creates YXmlEvent and calls observers.
|
||||
* @private
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
@@ -240,6 +259,9 @@ export class YXmlFragment extends AbstractType {
|
||||
return typeListMap(this, xml => xml.toString()).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
@@ -291,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.
|
||||
*
|
||||
@@ -307,6 +355,7 @@ export class YXmlFragment extends AbstractType {
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms this YArray to a JavaScript Array.
|
||||
*
|
||||
@@ -315,22 +364,61 @@ export class YXmlFragment extends AbstractType {
|
||||
toArray () {
|
||||
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.
|
||||
*
|
||||
* @private
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YXmlFragmentRefID)
|
||||
encoder.writeTypeRef(YXmlFragmentRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @private
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import {
|
||||
YMap,
|
||||
YXmlHookRefID
|
||||
YXmlHookRefID,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* You can manage binding to a custom type with YXmlHook.
|
||||
@@ -25,13 +24,22 @@ export class YXmlHook extends YMap {
|
||||
|
||||
/**
|
||||
* Creates an Item with the same effect as this Item (without position effect)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
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.
|
||||
*
|
||||
@@ -68,23 +76,20 @@ export class YXmlHook extends YMap {
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
*
|
||||
* @private
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
super._write(encoder)
|
||||
encoding.writeVarUint(encoder, YXmlHookRefID)
|
||||
encoding.writeVarString(encoder, this.hookName)
|
||||
encoder.writeTypeRef(YXmlHookRefID)
|
||||
encoder.writeKey(this.hookName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlHook}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlHook = decoder =>
|
||||
new YXmlHook(decoding.readVarString(decoder))
|
||||
new YXmlHook(decoder.readKey())
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
|
||||
import { YText, YXmlTextRefID } from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
import {
|
||||
YText,
|
||||
YXmlTextRefID,
|
||||
ContentType, YXmlElement, AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* Represents text in a Dom Element. In the future this type will also handle
|
||||
* simple formatting information like bold and italic.
|
||||
*/
|
||||
export class YXmlText extends YText {
|
||||
/**
|
||||
* @type {YXmlElement|YXmlText|null}
|
||||
*/
|
||||
get nextSibling () {
|
||||
const n = this._item ? this._item.next : null
|
||||
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {YXmlElement|YXmlText|null}
|
||||
*/
|
||||
get prevSibling () {
|
||||
const n = this._item ? this._item.prev : null
|
||||
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YXmlText()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {YXmlText}
|
||||
*/
|
||||
clone () {
|
||||
const text = new YXmlText()
|
||||
text.applyDelta(this.toDelta())
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dom Element that mirrors this YXmlText.
|
||||
*
|
||||
@@ -39,9 +66,9 @@ export class YXmlText extends YText {
|
||||
// @ts-ignore
|
||||
return this.toDelta().map(delta => {
|
||||
const nestedNodes = []
|
||||
for (let nodeName in delta.attributes) {
|
||||
for (const nodeName in delta.attributes) {
|
||||
const attrs = []
|
||||
for (let key in delta.attributes[nodeName]) {
|
||||
for (const key in delta.attributes[nodeName]) {
|
||||
attrs.push({ key, value: delta.attributes[nodeName][key] })
|
||||
}
|
||||
// sort attributes to get a unique order
|
||||
@@ -56,7 +83,7 @@ export class YXmlText extends YText {
|
||||
const node = nestedNodes[i]
|
||||
str += `<${node.nodeName}`
|
||||
for (let j = 0; j < node.attrs.length; j++) {
|
||||
const attr = node.attrs[i]
|
||||
const attr = node.attrs[j]
|
||||
str += ` ${attr.key}="${attr.value}"`
|
||||
}
|
||||
str += '>'
|
||||
@@ -69,22 +96,23 @@ export class YXmlText extends YText {
|
||||
}).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
* @private
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YXmlTextRefID)
|
||||
encoder.writeTypeRef(YXmlTextRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlText}
|
||||
*
|
||||
* @private
|
||||
|
||||
26
src/utils/AbstractConnector.js
Normal file
26
src/utils/AbstractConnector.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Observable } from 'lib0/observable.js'
|
||||
|
||||
import {
|
||||
Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* This is an abstract interface that all Connectors should implement to keep them interchangeable.
|
||||
*
|
||||
* @note This interface is experimental and it is not advised to actually inherit this class.
|
||||
* It just serves as typing information.
|
||||
*
|
||||
* @extends {Observable<any>}
|
||||
*/
|
||||
export class AbstractConnector extends Observable {
|
||||
/**
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} awareness
|
||||
*/
|
||||
constructor (ydoc, awareness) {
|
||||
super()
|
||||
this.doc = ydoc
|
||||
this.awareness = awareness
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
import {
|
||||
findIndexSS,
|
||||
createID,
|
||||
getState,
|
||||
splitItem,
|
||||
iterateStructs,
|
||||
Item, GC, StructStore, Transaction, ID // eslint-disable-line
|
||||
AbstractUpdateDecoder, AbstractDSDecoder, AbstractDSEncoder, DSDecoderV2, DSEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as array from 'lib0/array.js'
|
||||
@@ -42,7 +41,6 @@ export class DeleteSet {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<DeleteItem>>}
|
||||
* @private
|
||||
*/
|
||||
this.clients = new Map()
|
||||
}
|
||||
@@ -164,14 +162,15 @@ export const mergeDeleteSets = dss => {
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {ID} id
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {number} length
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const addToDeleteSet = (ds, id, length) => {
|
||||
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
|
||||
export const addToDeleteSet = (ds, client, clock, length) => {
|
||||
map.setIfUndefined(ds.clients, client, () => []).push(new DeleteItem(clock, length))
|
||||
}
|
||||
|
||||
export const createDeleteSet = () => new DeleteSet()
|
||||
@@ -211,28 +210,29 @@ export const createDeleteSetFromStructStore = ss => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractDSEncoder} encoder
|
||||
* @param {DeleteSet} ds
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeDeleteSet = (encoder, ds) => {
|
||||
encoding.writeVarUint(encoder, ds.clients.size)
|
||||
encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
|
||||
ds.clients.forEach((dsitems, client) => {
|
||||
encoding.writeVarUint(encoder, client)
|
||||
encoder.resetDsCurVal()
|
||||
encoding.writeVarUint(encoder.restEncoder, client)
|
||||
const len = dsitems.length
|
||||
encoding.writeVarUint(encoder, len)
|
||||
encoding.writeVarUint(encoder.restEncoder, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = dsitems[i]
|
||||
encoding.writeVarUint(encoder, item.clock)
|
||||
encoding.writeVarUint(encoder, item.len)
|
||||
encoder.writeDsClock(item.clock)
|
||||
encoder.writeDsLen(item.len)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractDSDecoder} decoder
|
||||
* @return {DeleteSet}
|
||||
*
|
||||
* @private
|
||||
@@ -240,19 +240,27 @@ export const writeDeleteSet = (encoder, ds) => {
|
||||
*/
|
||||
export const readDeleteSet = decoder => {
|
||||
const ds = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder)
|
||||
const numClients = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder))
|
||||
decoder.resetDsCurVal()
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
|
||||
if (numberOfDeletes > 0) {
|
||||
const dsField = map.setIfUndefined(ds.clients, client, () => [])
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array()..
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {AbstractDSDecoder} decoder
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*
|
||||
@@ -261,18 +269,19 @@ export const readDeleteSet = decoder => {
|
||||
*/
|
||||
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
|
||||
const unappliedDS = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder)
|
||||
const numClients = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder)
|
||||
decoder.resetDsCurVal()
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
|
||||
const structs = store.clients.get(client) || []
|
||||
const state = getState(store, client)
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
const clock = decoding.readVarUint(decoder)
|
||||
const len = decoding.readVarUint(decoder)
|
||||
const clock = decoder.readDsClock()
|
||||
const clockEnd = clock + decoder.readDsLen()
|
||||
if (clock < state) {
|
||||
if (state < clock + len) {
|
||||
addToDeleteSet(unappliedDS, createID(client, state), clock + len - state)
|
||||
if (state < clockEnd) {
|
||||
addToDeleteSet(unappliedDS, client, state, clockEnd - state)
|
||||
}
|
||||
let index = findIndexSS(structs, clock)
|
||||
/**
|
||||
@@ -289,10 +298,10 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => {
|
||||
while (index < structs.length) {
|
||||
// @ts-ignore
|
||||
struct = structs[index++]
|
||||
if (struct.id.clock < clock + len) {
|
||||
if (struct.id.clock < clockEnd) {
|
||||
if (!struct.deleted) {
|
||||
if (clock + len < struct.id.clock + struct.length) {
|
||||
structs.splice(index, 0, splitItem(transaction, struct, clock + len - struct.id.clock))
|
||||
if (clockEnd < struct.id.clock + struct.length) {
|
||||
structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock))
|
||||
}
|
||||
struct.delete(transaction)
|
||||
}
|
||||
@@ -301,14 +310,14 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addToDeleteSet(unappliedDS, createID(client, clock), len)
|
||||
addToDeleteSet(unappliedDS, client, clock, clockEnd - clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unappliedDS.clients.size > 0) {
|
||||
// TODO: no need for encoding+decoding ds anymore
|
||||
const unappliedDSEncoder = encoding.createEncoder()
|
||||
const unappliedDSEncoder = new DSEncoderV2()
|
||||
writeDeleteSet(unappliedDSEncoder, unappliedDS)
|
||||
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))
|
||||
store.pendingDeleteReaders.push(new DSDecoderV2(decoding.createDecoder((unappliedDSEncoder.toUint8Array()))))
|
||||
}
|
||||
}
|
||||
|
||||
134
src/utils/Doc.js
134
src/utils/Doc.js
@@ -10,12 +10,24 @@ 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.
|
||||
@@ -23,12 +35,14 @@ import * as map from 'lib0/map.js'
|
||||
*/
|
||||
export class Doc extends Observable {
|
||||
/**
|
||||
* @param {Object|undefined} conf configuration
|
||||
* @param {DocOpts} [opts] configuration
|
||||
*/
|
||||
constructor (conf = {}) {
|
||||
constructor ({ guid = random.uuidv4(), gc = true, gcFilter = () => true, meta = null, autoLoad = false } = {}) {
|
||||
super()
|
||||
this.gc = conf.gc || true
|
||||
this.clientID = random.uint32()
|
||||
this.gc = gc
|
||||
this.gcFilter = gcFilter
|
||||
this.clientID = generateNewClientId()
|
||||
this.guid = guid
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
*/
|
||||
@@ -36,15 +50,51 @@ export class Doc extends Observable {
|
||||
this.store = new StructStore()
|
||||
/**
|
||||
* @type {Transaction | null}
|
||||
* @private
|
||||
*/
|
||||
this._transaction = null
|
||||
/**
|
||||
* @type {Array<Transaction>}
|
||||
* @private
|
||||
*/
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes that happen inside of a transaction are bundled. This means that
|
||||
* the observer fires _after_ the transaction is finished and that all changes
|
||||
@@ -59,6 +109,7 @@ export class Doc extends Observable {
|
||||
transact (f, origin = null) {
|
||||
transact(this, f, origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a shared data type.
|
||||
*
|
||||
@@ -100,6 +151,7 @@ export class Doc extends Observable {
|
||||
t._map = type._map
|
||||
type._map.forEach(/** @param {Item?} n */ n => {
|
||||
for (; n !== null; n = n.left) {
|
||||
// @ts-ignore
|
||||
n.parent = t
|
||||
}
|
||||
})
|
||||
@@ -117,63 +169,109 @@ export class Doc extends Observable {
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} name
|
||||
* @param {string} [name]
|
||||
* @return {YArray<T>}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getArray (name) {
|
||||
getArray (name = '') {
|
||||
// @ts-ignore
|
||||
return this.get(name, YArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} [name]
|
||||
* @return {YText}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getText (name) {
|
||||
getText (name = '') {
|
||||
// @ts-ignore
|
||||
return this.get(name, YText)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} [name]
|
||||
* @return {YMap<any>}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getMap (name) {
|
||||
getMap (name = '') {
|
||||
// @ts-ignore
|
||||
return this.get(name, YMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} [name]
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getXmlFragment (name) {
|
||||
getXmlFragment (name = '') {
|
||||
// @ts-ignore
|
||||
return this.get(name, YXmlFragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the entire document into a js object, recursively traversing each yjs type
|
||||
* Doesn't log types that have not been defined (using ydoc.getType(..)).
|
||||
*
|
||||
* @deprecated Do not use this method and rather call toJSON directly on the shared types.
|
||||
*
|
||||
* @return {Object<string, any>}
|
||||
*/
|
||||
toJSON () {
|
||||
/**
|
||||
* @type {Object<string, any>}
|
||||
*/
|
||||
const doc = {}
|
||||
|
||||
this.share.forEach((value, key) => {
|
||||
doc[key] = value.toJSON()
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `destroy` event and unregister all event handlers.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventName
|
||||
* @param {function} f
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,7 +81,7 @@ export const readID = decoder =>
|
||||
*/
|
||||
export const findRootTypeKey = type => {
|
||||
// @ts-ignore _y must be defined, otherwise unexpected case
|
||||
for (let [key, value] of type.doc.share) {
|
||||
for (const [key, value] of type.doc.share.entries()) {
|
||||
if (value === type) {
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -5,25 +5,24 @@ import {
|
||||
readDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSet,
|
||||
ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
|
||||
DSEncoderV1, DSDecoderV1, ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
|
||||
|
||||
export class PermanentUserData {
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {string} key
|
||||
* @param {YMap<any>} [storeType]
|
||||
*/
|
||||
constructor (doc, key = 'users') {
|
||||
const users = doc.getMap(key)
|
||||
constructor (doc, storeType = doc.getMap('users')) {
|
||||
/**
|
||||
* @type {Map<string,DeleteSet>}
|
||||
*/
|
||||
const dss = new Map()
|
||||
this.yusers = users
|
||||
this.yusers = storeType
|
||||
this.doc = doc
|
||||
/**
|
||||
* Maps from clientid to userDescription
|
||||
@@ -47,32 +46,35 @@ export class PermanentUserData {
|
||||
event.changes.added.forEach(item => {
|
||||
item.content.getContent().forEach(encodedDs => {
|
||||
if (encodedDs instanceof Uint8Array) {
|
||||
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(decoding.createDecoder(encodedDs))]))
|
||||
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs)))]))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(decoding.createDecoder(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))
|
||||
)
|
||||
ids.forEach(addClientId)
|
||||
}
|
||||
// observe users
|
||||
users.observe(event => {
|
||||
storeType.observe(event => {
|
||||
event.keysChanged.forEach(userDescription =>
|
||||
initUser(users.get(userDescription), userDescription)
|
||||
initUser(storeType.get(userDescription), userDescription)
|
||||
)
|
||||
})
|
||||
// add intial data
|
||||
users.forEach(initUser)
|
||||
storeType.forEach(initUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {number} clientid
|
||||
* @param {string} userDescription
|
||||
* @param {Object} [conf]
|
||||
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
|
||||
*/
|
||||
setUserMapping (doc, clientid, userDescription) {
|
||||
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
|
||||
const users = this.yusers
|
||||
let user = users.get(userDescription)
|
||||
if (!user) {
|
||||
@@ -83,35 +85,40 @@ export class PermanentUserData {
|
||||
}
|
||||
user.get('ids').push([clientid])
|
||||
users.observe(event => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
// user was overwritten, port all data over to the next user object
|
||||
// @todo Experiment with Y.Sets here
|
||||
user = userOverwrite
|
||||
// @todo iterate over old type
|
||||
this.clients.forEach((_userDescription, clientid) => {
|
||||
if (userDescription === _userDescription) {
|
||||
user.get('ids').push([clientid])
|
||||
setTimeout(() => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
// user was overwritten, port all data over to the next user object
|
||||
// @todo Experiment with Y.Sets here
|
||||
user = userOverwrite
|
||||
// @todo iterate over old type
|
||||
this.clients.forEach((_userDescription, clientid) => {
|
||||
if (userDescription === _userDescription) {
|
||||
user.get('ids').push([clientid])
|
||||
}
|
||||
})
|
||||
const encoder = new DSEncoderV1()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoder.toUint8Array()])
|
||||
}
|
||||
})
|
||||
const encoder = encoding.createEncoder()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
const yds = user.get('ds')
|
||||
const ds = transaction.deleteSet
|
||||
if (transaction.local && ds.clients.size > 0) {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
setTimeout(() => {
|
||||
const yds = user.get('ds')
|
||||
const ds = transaction.deleteSet
|
||||
if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) {
|
||||
const encoder = new DSEncoderV1()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoder.toUint8Array()])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clientid
|
||||
* @return {any}
|
||||
@@ -119,12 +126,13 @@ export class PermanentUserData {
|
||||
getUserByClientId (clientid) {
|
||||
return this.clients.get(clientid) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @return {string | null}
|
||||
*/
|
||||
getUserByDeletedId (id) {
|
||||
for (const [userDescription, ds] of this.dss) {
|
||||
for (const [userDescription, ds] of this.dss.entries()) {
|
||||
if (isDeleted(ds, id)) {
|
||||
return userDescription
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
import {
|
||||
createID,
|
||||
writeID,
|
||||
readID,
|
||||
compareIDs,
|
||||
getState,
|
||||
findRootTypeKey,
|
||||
Item,
|
||||
createID,
|
||||
ContentType,
|
||||
followRedone,
|
||||
ID, Doc, AbstractType // eslint-disable-line
|
||||
@@ -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,11 +60,23 @@ export class RelativePosition {
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.item = item
|
||||
/**
|
||||
* A relative position is associated to a specific character. By default
|
||||
* assoc >= 0, the relative position is associated to the character
|
||||
* after the meant position.
|
||||
* I.e. position 1 in 'ab' is associated to character 'b'.
|
||||
*
|
||||
* If assoc < 0, then the relative position is associated to the caharacter
|
||||
* before the meant position.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.assoc = assoc
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} json
|
||||
* @param {any} json
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
@@ -74,8 +87,9 @@ 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,32 +98,35 @@ 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) {
|
||||
tname = findRootTypeKey(type)
|
||||
} else {
|
||||
typeid = type._item.id
|
||||
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 +134,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 +172,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 +187,7 @@ export const writeRelativePosition = (encoder, rpos) => {
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
encoding.writeVarInt(encoder, assoc)
|
||||
return encoder
|
||||
}
|
||||
|
||||
@@ -173,7 +203,7 @@ export const encodeRelativePosition = rpos => {
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {RelativePosition|null}
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
@@ -195,12 +225,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 +247,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) {
|
||||
@@ -227,9 +259,9 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
if (!(right instanceof Item)) {
|
||||
return null
|
||||
}
|
||||
type = right.parent
|
||||
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 +288,22 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
index = type._length
|
||||
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
|
||||
)
|
||||
|
||||
@@ -4,21 +4,25 @@ import {
|
||||
createDeleteSetFromStructStore,
|
||||
getStateVector,
|
||||
getItemCleanStart,
|
||||
createID,
|
||||
iterateDeletedStructs,
|
||||
writeDeleteSet,
|
||||
writeStateVector,
|
||||
readDeleteSet,
|
||||
readStateVector,
|
||||
createDeleteSet,
|
||||
createID,
|
||||
getState,
|
||||
Transaction, Doc, DeleteSet, Item // eslint-disable-line
|
||||
findIndexSS,
|
||||
UpdateEncoderV2,
|
||||
DefaultDSEncoder,
|
||||
applyUpdateV2,
|
||||
AbstractDSDecoder, AbstractDSEncoder, 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 encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
@@ -28,13 +32,11 @@ export class Snapshot {
|
||||
constructor (ds, sv) {
|
||||
/**
|
||||
* @type {DeleteSet}
|
||||
* @private
|
||||
*/
|
||||
this.ds = ds
|
||||
/**
|
||||
* State Map
|
||||
* @type {Map<number,number>}
|
||||
* @private
|
||||
*/
|
||||
this.sv = sv
|
||||
}
|
||||
@@ -53,12 +55,12 @@ export const equalSnapshots = (snap1, snap2) => {
|
||||
if (sv1.size !== sv2.size || ds1.size !== ds2.size) {
|
||||
return false
|
||||
}
|
||||
for (const [key, value] of sv1) {
|
||||
for (const [key, value] of sv1.entries()) {
|
||||
if (sv2.get(key) !== value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const [client, dsitems1] of ds1) {
|
||||
for (const [client, dsitems1] of ds1.entries()) {
|
||||
const dsitems2 = ds2.get(client) || []
|
||||
if (dsitems1.length !== dsitems2.length) {
|
||||
return false
|
||||
@@ -76,23 +78,35 @@ export const equalSnapshots = (snap1, snap2) => {
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {AbstractDSEncoder} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshot = snapshot => {
|
||||
const encoder = encoding.createEncoder()
|
||||
export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
|
||||
writeDeleteSet(encoder, snapshot.ds)
|
||||
writeStateVector(encoder, snapshot.sv)
|
||||
return encoding.toUint8Array(encoder)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DefaultDSEncoder())
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @param {AbstractDSDecoder} [decoder]
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(decoding.createDecoder(buf))) => {
|
||||
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshot = buf => {
|
||||
const decoder = decoding.createDecoder(buf)
|
||||
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
|
||||
}
|
||||
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new DSDecoderV1(decoding.createDecoder(buf)))
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
@@ -138,3 +152,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
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
import {
|
||||
GC,
|
||||
splitItem,
|
||||
GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line
|
||||
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
|
||||
export class StructStore {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<GC|Item>>}
|
||||
* @private
|
||||
*/
|
||||
this.clients = new Map()
|
||||
/**
|
||||
@@ -22,20 +20,17 @@ export class StructStore {
|
||||
* 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<GCRef|ItemRef>}>}
|
||||
* @private
|
||||
* @type {Map<number,{i:number,refs:Array<GC|Item>}>}
|
||||
*/
|
||||
this.pendingClientsStructRefs = new Map()
|
||||
/**
|
||||
* Stack of pending structs waiting for struct dependencies
|
||||
* Maximum length of stack is structReaders.size
|
||||
* @type {Array<GCRef|ItemRef>}
|
||||
* @private
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
this.pendingStack = []
|
||||
/**
|
||||
* @type {Array<decoding.Decoder>}
|
||||
* @private
|
||||
* @type {Array<DSDecoderV2>}
|
||||
*/
|
||||
this.pendingDeleteReaders = []
|
||||
}
|
||||
@@ -118,7 +113,7 @@ export const addStruct = (store, struct) => {
|
||||
|
||||
/**
|
||||
* Perform a binary search on a sorted array
|
||||
* @param {Array<any>} structs
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clock
|
||||
* @return {number}
|
||||
*
|
||||
@@ -128,10 +123,18 @@ export const addStruct = (store, struct) => {
|
||||
export const findIndexSS = (structs, clock) => {
|
||||
let left = 0
|
||||
let right = structs.length - 1
|
||||
let mid = structs[right]
|
||||
let midclock = mid.id.clock
|
||||
if (midclock === clock) {
|
||||
return right
|
||||
}
|
||||
// @todo does it even make sense to pivot the search?
|
||||
// If a good split misses, it might actually increase the time to find the correct item.
|
||||
// Currently, the only advantage is that search with pivoting might find the item on the first try.
|
||||
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
|
||||
while (left <= right) {
|
||||
const midindex = math.floor((left + right) / 2)
|
||||
const mid = structs[midindex]
|
||||
const midclock = mid.id.clock
|
||||
mid = structs[midindex]
|
||||
midclock = mid.id.clock
|
||||
if (midclock <= clock) {
|
||||
if (clock < midclock + mid.length) {
|
||||
return midindex
|
||||
@@ -140,6 +143,7 @@ export const findIndexSS = (structs, clock) => {
|
||||
} else {
|
||||
right = midindex - 1
|
||||
}
|
||||
midindex = math.floor((left + right) / 2)
|
||||
}
|
||||
// Always check state before looking for a struct in StructStore
|
||||
// Therefore the case of not finding a struct is unexpected
|
||||
@@ -167,16 +171,10 @@ export const find = (store, id) => {
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {Item}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
// @ts-ignore
|
||||
export const getItem = (store, id) => find(store, id)
|
||||
export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
@@ -185,7 +183,7 @@ export const getItem = (store, id) => find(store, id)
|
||||
*/
|
||||
export const findIndexCleanStart = (transaction, structs, clock) => {
|
||||
const index = findIndexSS(structs, clock)
|
||||
let struct = structs[index]
|
||||
const struct = structs[index]
|
||||
if (struct.id.clock < clock && struct instanceof Item) {
|
||||
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
|
||||
return index + 1
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
import {
|
||||
getState,
|
||||
createID,
|
||||
writeStructsFromTransaction,
|
||||
writeDeleteSet,
|
||||
DeleteSet,
|
||||
@@ -10,13 +9,16 @@ import {
|
||||
findIndexSS,
|
||||
callEventHandlerListeners,
|
||||
Item,
|
||||
ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
generateNewClientId,
|
||||
createID,
|
||||
AbstractUpdateEncoder, GC, StructStore, UpdateEncoderV2, DefaultUpdateEncoder, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as logging from 'lib0/logging.js'
|
||||
import { callAll } from 'lib0/function.js'
|
||||
|
||||
/**
|
||||
* A transaction is created for every change on the Yjs model. It is possible
|
||||
@@ -72,7 +74,7 @@ export class Transaction {
|
||||
/**
|
||||
* All types that were directly modified (property added or child
|
||||
* inserted/deleted). New types are not included in this Set.
|
||||
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
|
||||
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
|
||||
*/
|
||||
this.changed = new Map()
|
||||
@@ -83,10 +85,9 @@ export class Transaction {
|
||||
*/
|
||||
this.changedParentTypes = new Map()
|
||||
/**
|
||||
* @type {Set<ID>}
|
||||
* @private
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
this._mergeStructs = new Set()
|
||||
this._mergeStructs = []
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
@@ -101,21 +102,34 @@ 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 {Transaction} transaction
|
||||
* @return {boolean} Whether data was written.
|
||||
*/
|
||||
export const computeUpdateMessageFromTransaction = transaction => {
|
||||
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
|
||||
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
const encoder = encoding.createEncoder()
|
||||
sortAndMergeDeleteSet(transaction.deleteSet)
|
||||
writeStructsFromTransaction(encoder, transaction)
|
||||
writeDeleteSet(encoder, transaction.deleteSet)
|
||||
return encoder
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +158,214 @@ export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType<any>} */ (right.parent)._map.get(right.parentSub) === right) {
|
||||
/** @type {AbstractType<any>} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
const tryGcDeleteSet = (ds, store, gcFilter) => {
|
||||
for (const [client, deleteItems] of ds.clients.entries()) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
const tryMergeDeleteSet = (ds, store) => {
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
ds.clients.forEach((deleteItems, client) => {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
export const tryGc = (ds, store, gcFilter) => {
|
||||
tryGcDeleteSet(ds, store, gcFilter)
|
||||
tryMergeDeleteSet(ds, store)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Transaction>} transactionCleanups
|
||||
* @param {number} i
|
||||
*/
|
||||
const cleanupTransactions = (transactionCleanups, i) => {
|
||||
if (i < transactionCleanups.length) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ds = transaction.deleteSet
|
||||
const mergeStructs = transaction._mergeStructs
|
||||
try {
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
*
|
||||
* Each callback is called even if the other ones throw errors.
|
||||
*
|
||||
* @type {Array<function():void>}
|
||||
*/
|
||||
const fs = []
|
||||
// observe events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) =>
|
||||
fs.push(() => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => {
|
||||
// deep observe events
|
||||
transaction.changedParentTypes.forEach((events, type) =>
|
||||
fs.push(() => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.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)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||
})
|
||||
callAll(fs, [])
|
||||
} finally {
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
tryGcDeleteSet(ds, store, doc.gcFilter)
|
||||
}
|
||||
tryMergeDeleteSet(ds, store)
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
transaction.afterState.forEach((clock, client) => {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (let i = 0; i < mergeStructs.length; i++) {
|
||||
const { client, clock } = mergeStructs[i].id
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
|
||||
doc.clientID = generateNewClientId()
|
||||
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const encoder = new DefaultUpdateEncoder()
|
||||
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||
if (hasContent) {
|
||||
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, 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])
|
||||
} else {
|
||||
cleanupTransactions(transactionCleanups, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
@@ -151,7 +373,6 @@ export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
* @param {function(Transaction):void} f
|
||||
* @param {any} [origin=true]
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const transact = (doc, f, origin = null, local = true) => {
|
||||
@@ -161,6 +382,9 @@ export const transact = (doc, f, origin = null, local = true) => {
|
||||
initialCall = true
|
||||
doc._transaction = new Transaction(doc, origin, local)
|
||||
transactionCleanups.push(doc._transaction)
|
||||
if (transactionCleanups.length === 1) {
|
||||
doc.emit('beforeAllTransactions', [doc])
|
||||
}
|
||||
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||
}
|
||||
try {
|
||||
@@ -169,134 +393,13 @@ export const transact = (doc, f, origin = null, local = true) => {
|
||||
if (initialCall && transactionCleanups[0] === doc._transaction) {
|
||||
// The first transaction ended, now process observer calls.
|
||||
// Observer call may create new transactions for which we need to call the observers and do cleanup.
|
||||
// We don't want to nest these calls, so we execute these calls one after another
|
||||
for (let i = 0; i < transactionCleanups.length; i++) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const store = transaction.doc.store
|
||||
const ds = transaction.deleteSet
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
// emit change events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
transaction.changedParentTypes.forEach((events, type) => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// We don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
}
|
||||
})
|
||||
doc.emit('afterTransaction', [transaction, doc])
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
for (const [client, clock] of transaction.afterState) {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (const mid of transaction._mergeStructs) {
|
||||
const client = mid.client
|
||||
const clock = mid.clock
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
// @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 updateMessage = computeUpdateMessageFromTransaction(transaction)
|
||||
if (updateMessage !== null) {
|
||||
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
}
|
||||
doc._transactionCleanups = []
|
||||
// We don't want to nest these calls, so we execute these calls one after
|
||||
// another.
|
||||
// Also we need to ensure that all cleanups are called, even if the
|
||||
// observes throw errors.
|
||||
// This file is full of hacky try {} finally {} blocks to ensure that an
|
||||
// event can throw errors and also that the cleanup is called.
|
||||
cleanupTransactions(transactionCleanups, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
iterateDeletedStructs,
|
||||
keepItem,
|
||||
transact,
|
||||
createID,
|
||||
redoItem,
|
||||
iterateStructs,
|
||||
isParentOf,
|
||||
createID,
|
||||
followRedone,
|
||||
getItemCleanStart,
|
||||
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
|
||||
getState,
|
||||
ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as time from 'lib0/time.js'
|
||||
@@ -18,13 +19,13 @@ import { Observable } from 'lib0/observable.js'
|
||||
class StackItem {
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {number} start clock start of the local client
|
||||
* @param {number} len
|
||||
* @param {Map<number,number>} beforeState
|
||||
* @param {Map<number,number>} afterState
|
||||
*/
|
||||
constructor (ds, start, len) {
|
||||
constructor (ds, beforeState, afterState) {
|
||||
this.ds = ds
|
||||
this.start = start
|
||||
this.len = len
|
||||
this.beforeState = beforeState
|
||||
this.afterState = afterState
|
||||
/**
|
||||
* Use this to save and restore metadata like selection range
|
||||
*/
|
||||
@@ -50,36 +51,64 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
while (stack.length > 0 && result === null) {
|
||||
const store = doc.store
|
||||
const stackItem = /** @type {StackItem} */ (stack.pop())
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsToRedo = new Set()
|
||||
let performedChange = false
|
||||
iterateDeletedStructs(transaction, stackItem.ds, struct => {
|
||||
if (struct instanceof Item && scope.some(type => isParentOf(type, struct))) {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(item => {
|
||||
performedChange = redoItem(transaction, item, itemsToRedo) !== null || performedChange
|
||||
})
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(doc.clientID))
|
||||
/**
|
||||
* @type {Array<Item>}
|
||||
*/
|
||||
const itemsToDelete = []
|
||||
iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => {
|
||||
if (struct instanceof Item && !struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
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 > stackItem.len) {
|
||||
getItemCleanStart(transaction, createID(item.id.client, item.id.clock + stackItem.len))
|
||||
}
|
||||
struct = item
|
||||
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))
|
||||
}
|
||||
itemsToDelete.push(struct)
|
||||
iterateStructs(transaction, structs, startClock, len, struct => {
|
||||
if (struct instanceof Item) {
|
||||
if (struct.redone !== null) {
|
||||
let { item, diff } = followRedone(store, struct.id)
|
||||
if (diff > 0) {
|
||||
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
|
||||
}
|
||||
if (item.length > len) {
|
||||
getItemCleanStart(transaction, createID(item.id.client, endClock))
|
||||
}
|
||||
struct = item
|
||||
}
|
||||
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
itemsToDelete.push(struct)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
iterateDeletedStructs(transaction, stackItem.ds, struct => {
|
||||
const id = struct.id
|
||||
const clock = id.clock
|
||||
const client = id.client
|
||||
const startClock = stackItem.beforeState.get(client) || 0
|
||||
const endClock = stackItem.afterState.get(client) || 0
|
||||
if (
|
||||
struct instanceof Item &&
|
||||
scope.some(type => isParentOf(type, struct)) &&
|
||||
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
|
||||
!(clock >= startClock && clock < endClock)
|
||||
) {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
|
||||
})
|
||||
// We want to delete in reverse order so that children are deleted before
|
||||
// parents, so we have more information available when items are filtered.
|
||||
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
|
||||
@@ -90,11 +119,17 @@ 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
|
||||
}
|
||||
})
|
||||
}, undoManager)
|
||||
if (result != null) {
|
||||
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -111,9 +146,9 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
/**
|
||||
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
|
||||
* the redo-stack. You may store additional stack information via the
|
||||
* metadata property on `event.stackItem.metadata` (it is a `Map` of metadata properties).
|
||||
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
|
||||
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.metadata`.
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||
*
|
||||
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
|
||||
*/
|
||||
@@ -122,10 +157,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
|
||||
@@ -162,17 +194,17 @@ export class UndoManager extends Observable {
|
||||
// neither undoing nor redoing: delete redoStack
|
||||
this.redoStack = []
|
||||
}
|
||||
const beforeState = transaction.beforeState.get(this.doc.clientID) || 0
|
||||
const afterState = transaction.afterState.get(this.doc.clientID) || 0
|
||||
const beforeState = transaction.beforeState
|
||||
const afterState = transaction.afterState
|
||||
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.len = afterState - lastOp.start
|
||||
lastOp.afterState = afterState
|
||||
} else {
|
||||
// create a new stack op
|
||||
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState))
|
||||
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
|
||||
}
|
||||
if (!undoing && !redoing) {
|
||||
this.lastChange = now
|
||||
@@ -180,13 +212,32 @@ export class UndoManager extends Observable {
|
||||
// make sure that deleted structs are not gc'd
|
||||
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item)
|
||||
keepItem(item, true)
|
||||
}
|
||||
})
|
||||
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
|
||||
})
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.doc.transact(transaction => {
|
||||
/**
|
||||
* @param {StackItem} stackItem
|
||||
*/
|
||||
const clearItem = stackItem => {
|
||||
iterateDeletedStructs(transaction, stackItem.ds, item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.undoStack.forEach(clearItem)
|
||||
this.redoStack.forEach(clearItem)
|
||||
})
|
||||
this.undoStack = []
|
||||
this.redoStack = []
|
||||
}
|
||||
|
||||
/**
|
||||
* UndoManager merges Undo-StackItem if they are created within time-gap
|
||||
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
|
||||
|
||||
392
src/utils/UpdateDecoder.js
Normal file
392
src/utils/UpdateDecoder.js
Normal file
@@ -0,0 +1,392 @@
|
||||
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
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsClock () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV1 extends DSDecoderV1 {
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return decoding.readUint8(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} isKey
|
||||
*/
|
||||
readParentInfo () {
|
||||
return decoding.readVarUint(this.restDecoder) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number} len
|
||||
*/
|
||||
readLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation uses JSON parse. We use any-decoding in v2.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return JSON.parse(decoding.readVarString(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class DSDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.dsCurrVal = 0
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
readDsClock () {
|
||||
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
|
||||
return this.dsCurrVal
|
||||
}
|
||||
|
||||
readDsLen () {
|
||||
const diff = decoding.readVarUint(this.restDecoder) + 1
|
||||
this.dsCurrVal += diff
|
||||
return diff
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV2 extends DSDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
super(decoder)
|
||||
/**
|
||||
* List of cached keys. If the keys[id] does not exist, we read a new key
|
||||
* from stringEncoder and push it to keys.
|
||||
*
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
this.keys = []
|
||||
decoding.readUint8(decoder) // read feature flag - currently unused
|
||||
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return this.clientDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return /** @type {number} */ (this.infoDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return this.stringDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
readParentInfo () {
|
||||
return this.parentInfoDecoder.read() === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return this.typeRefDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
readLen () {
|
||||
return this.lenDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return decoding.readVarUint8Array(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
const keyClock = this.keyClockDecoder.read()
|
||||
if (keyClock < this.keys.length) {
|
||||
return this.keys[keyClock]
|
||||
} else {
|
||||
const key = this.stringDecoder.read()
|
||||
this.keys.push(key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
408
src/utils/UpdateEncoder.js
Normal file
408
src/utils/UpdateEncoder.js
Normal file
@@ -0,0 +1,408 @@
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeDsClock (clock) {
|
||||
encoding.writeVarUint(this.restEncoder, clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeDsLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV1 extends DSEncoderV1 {
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use writeClient and writeClock instead of writeID if possible.
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
encoding.writeVarUint(this.restEncoder, client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
encoding.writeUint8(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
encoding.writeVarString(this.restEncoder, s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
encoding.writeVarUint(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
encoding.writeVarString(this.restEncoder, key)
|
||||
}
|
||||
}
|
||||
|
||||
export class DSEncoderV2 {
|
||||
constructor () {
|
||||
this.restEncoder = new encoding.Encoder() // encodes all the rest / non-optimized
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeDsClock (clock) {
|
||||
const diff = clock - this.dsCurrVal
|
||||
this.dsCurrVal = clock
|
||||
encoding.writeVarUint(this.restEncoder, diff)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeDsLen (len) {
|
||||
if (len === 0) {
|
||||
error.unexpectedCase()
|
||||
}
|
||||
encoding.writeVarUint(this.restEncoder, len - 1)
|
||||
this.dsCurrVal += len
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV2 extends DSEncoderV2 {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,number>}
|
||||
*/
|
||||
this.keyMap = new Map()
|
||||
/**
|
||||
* Refers to the next uniqe key-identifier to me used.
|
||||
* See writeKey method for more information.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.keyClock = 0
|
||||
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.clientEncoder = new encoding.UintOptRleEncoder()
|
||||
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.stringEncoder = new encoding.StringEncoder()
|
||||
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.typeRefEncoder = new encoding.UintOptRleEncoder()
|
||||
this.lenEncoder = new encoding.UintOptRleEncoder()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeUint8(encoder, 0) // this is a feature flag that we might use in the future
|
||||
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
|
||||
// @note The rest encoder is appended! (note the missing var)
|
||||
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.leftClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.rightClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
this.clientEncoder.write(client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
this.infoEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
this.stringEncoder.write(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
this.parentInfoEncoder.write(isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
this.typeRefEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
this.lenEncoder.write(len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeAny(this.restEncoder, embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
|
||||
* occur very often. For a 3d application, the key `position` might occur very often.
|
||||
*
|
||||
* We cache these keys in a Map and refer to them via a unique number.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
const clock = this.keyMap.get(key)
|
||||
if (clock === undefined) {
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
this.stringEncoder.write(key)
|
||||
} else {
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -56,6 +58,8 @@ export class YEvent {
|
||||
/**
|
||||
* Check if a struct is deleted by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
@@ -66,6 +70,8 @@ export class YEvent {
|
||||
/**
|
||||
* Check if a struct is added by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
@@ -74,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
|
||||
@@ -106,7 +112,7 @@ export class YEvent {
|
||||
}
|
||||
for (let item = target._start; item !== null; item = item.right) {
|
||||
if (item.deleted) {
|
||||
if (this.deletes(item)) {
|
||||
if (this.deletes(item) && !this.adds(item)) {
|
||||
if (lastOp === null || lastOp.delete === undefined) {
|
||||
packOp()
|
||||
lastOp = { delete: 0 }
|
||||
@@ -177,7 +183,7 @@ export class YEvent {
|
||||
})
|
||||
this._changes = changes
|
||||
}
|
||||
return changes
|
||||
return /** @type {any} */ (changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +213,7 @@ const getPathTo = (parent, child) => {
|
||||
} else {
|
||||
// parent is array-ish
|
||||
let i = 0
|
||||
let c = child._item.parent._start
|
||||
let c = /** @type {AbstractType<any>} */ (child._item.parent)._start
|
||||
while (c !== child._item && c !== null) {
|
||||
if (!c.deleted) {
|
||||
i++
|
||||
@@ -216,7 +222,7 @@ const getPathTo = (parent, child) => {
|
||||
}
|
||||
path.unshift(i)
|
||||
}
|
||||
child = child._item.parent
|
||||
child = /** @type {AbstractType<any>} */ (child._item.parent)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
/**
|
||||
* @module encoding
|
||||
*
|
||||
*/
|
||||
/*
|
||||
* We use the first five bits in the info flag for determining the type of the struct.
|
||||
*
|
||||
* 0: GC
|
||||
@@ -16,27 +17,52 @@
|
||||
|
||||
import {
|
||||
findIndexSS,
|
||||
GCRef,
|
||||
ItemRef,
|
||||
writeID,
|
||||
createID,
|
||||
readID,
|
||||
getState,
|
||||
createID,
|
||||
getStateVector,
|
||||
readAndApplyDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
transact,
|
||||
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line
|
||||
readItemContent,
|
||||
UpdateDecoderV1,
|
||||
UpdateDecoderV2,
|
||||
UpdateEncoderV1,
|
||||
UpdateEncoderV2,
|
||||
DSDecoderV2,
|
||||
DSEncoderV2,
|
||||
DSDecoderV1,
|
||||
DSEncoderV1,
|
||||
AbstractDSEncoder, AbstractDSDecoder, AbstractUpdateEncoder, AbstractUpdateDecoder, AbstractContent, Doc, Transaction, GC, Item, StructStore, ID // 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
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Array<AbstractStruct>} structs All structs by `client`
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {Array<GC|Item>} structs All structs by `client`
|
||||
* @param {number} client
|
||||
* @param {number} clock write structs starting with `ID(client,clock)`
|
||||
*
|
||||
@@ -46,41 +72,19 @@ const writeStructs = (encoder, structs, client, clock) => {
|
||||
// write first id
|
||||
const startNewStructs = findIndexSS(structs, clock)
|
||||
// write # encoded structs
|
||||
encoding.writeVarUint(encoder, structs.length - startNewStructs)
|
||||
writeID(encoder, createID(client, clock))
|
||||
encoding.writeVarUint(encoder.restEncoder, structs.length - startNewStructs)
|
||||
encoder.writeClient(client)
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
const firstStruct = structs[startNewStructs]
|
||||
// write first struct with an offset
|
||||
firstStruct.write(encoder, clock - firstStruct.id.clock, 0)
|
||||
firstStruct.write(encoder, clock - firstStruct.id.clock)
|
||||
for (let i = startNewStructs + 1; i < structs.length; i++) {
|
||||
structs[i].write(encoder, 0, 0)
|
||||
structs[i].write(encoder, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {number} numOfStructs
|
||||
* @param {ID} nextID
|
||||
* @return {Array<GCRef|ItemRef>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const readStructRefs = (decoder, numOfStructs, nextID) => {
|
||||
/**
|
||||
* @type {Array<GCRef|ItemRef>}
|
||||
*/
|
||||
const refs = []
|
||||
for (let i = 0; i < numOfStructs; i++) {
|
||||
const info = decoding.readUint8(decoder)
|
||||
const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, nextID, info) : new ItemRef(decoder, nextID, info)
|
||||
nextID = createID(nextID.client, nextID.clock + ref.length)
|
||||
refs.push(ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {StructStore} store
|
||||
* @param {Map<number,number>} _sm
|
||||
*
|
||||
@@ -102,31 +106,93 @@ export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
}
|
||||
})
|
||||
// write # states that were updated
|
||||
encoding.writeVarUint(encoder, sm.size)
|
||||
sm.forEach((clock, client) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, sm.size)
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
// @ts-ignore
|
||||
writeStructs(encoder, store.clients.get(client), client, clock)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder The decoder object to read data from.
|
||||
* @return {Map<number,Array<GCRef|ItemRef>>}
|
||||
* @param {AbstractUpdateDecoder} decoder The decoder object to read data from.
|
||||
* @param {Map<number,Array<GC|Item>>} clientRefs
|
||||
* @param {Doc} doc
|
||||
* @return {Map<number,Array<GC|Item>>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readClientsStructRefs = decoder => {
|
||||
/**
|
||||
* @type {Map<number,Array<GCRef|ItemRef>>}
|
||||
*/
|
||||
const clientRefs = new Map()
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder)
|
||||
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||
const numberOfStructs = decoding.readVarUint(decoder)
|
||||
const nextID = readID(decoder)
|
||||
const refs = readStructRefs(decoder, numberOfStructs, nextID)
|
||||
clientRefs.set(nextID.client, refs)
|
||||
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
|
||||
/**
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
const refs = new Array(numberOfStructs)
|
||||
const client = decoder.readClient()
|
||||
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||
// const start = performance.now()
|
||||
clientRefs.set(client, refs)
|
||||
for (let i = 0; i < numberOfStructs; i++) {
|
||||
const info = decoder.readInfo()
|
||||
if ((binary.BITS5 & info) !== 0) {
|
||||
/**
|
||||
* The optimized implementation doesn't use any variables because inlining variables is faster.
|
||||
* Below a non-optimized version is shown that implements the basic algorithm with
|
||||
* a few comments
|
||||
*/
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const struct = new Item(
|
||||
createID(client, clock),
|
||||
null, // leftd
|
||||
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||
null, // right
|
||||
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||
cantCopyParentInfo ? (decoder.readParentInfo() ? doc.get(decoder.readString()) : decoder.readLeftID()) : null, // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
/* A non-optimized implementation of the above algorithm:
|
||||
|
||||
// The item that was originally to the left of this item.
|
||||
const origin = (info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null
|
||||
// The item that was originally to the right of this item.
|
||||
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
const hasParentYKey = cantCopyParentInfo ? decoder.readParentInfo() : false
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const parentYKey = cantCopyParentInfo && hasParentYKey ? decoder.readString() : null
|
||||
|
||||
const struct = new Item(
|
||||
createID(client, clock),
|
||||
null, // leftd
|
||||
origin, // origin
|
||||
null, // right
|
||||
rightOrigin, // right origin
|
||||
cantCopyParentInfo && !hasParentYKey ? decoder.readLeftID() : (parentYKey !== null ? doc.get(parentYKey) : null), // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
*/
|
||||
refs[i] = struct
|
||||
clock += struct.length
|
||||
} 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
|
||||
}
|
||||
return clientRefs
|
||||
}
|
||||
@@ -157,33 +223,55 @@ export const readClientsStructRefs = decoder => {
|
||||
* @function
|
||||
*/
|
||||
const resumeStructIntegration = (transaction, store) => {
|
||||
const stack = store.pendingStack
|
||||
const stack = store.pendingStack // @todo don't forget to append stackhead at the end
|
||||
const clientsStructRefs = store.pendingClientsStructRefs
|
||||
// iterate over all struct readers until we are done
|
||||
while (stack.length !== 0 || clientsStructRefs.size !== 0) {
|
||||
if (stack.length === 0) {
|
||||
// take any first struct from clientsStructRefs and put it on the stack
|
||||
const [client, structRefs] = clientsStructRefs.entries().next().value
|
||||
stack.push(structRefs.refs[structRefs.i++])
|
||||
if (structRefs.refs.length === structRefs.i) {
|
||||
clientsStructRefs.delete(client)
|
||||
// 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)
|
||||
if (clientsStructRefsIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const getNextStructTarget = () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
const ref = stack[stack.length - 1]
|
||||
const m = ref._missing
|
||||
const client = ref.id.client
|
||||
const localClock = getState(store, client)
|
||||
const offset = ref.id.clock < localClock ? localClock - ref.id.clock : 0
|
||||
if (ref.id.clock + offset !== localClock) {
|
||||
return nextStructsTarget
|
||||
}
|
||||
let curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null && stack.length === 0) {
|
||||
return
|
||||
}
|
||||
/**
|
||||
* @type {GC|Item}
|
||||
*/
|
||||
let stackHead = stack.length > 0
|
||||
? /** @type {GC|Item} */ (stack.pop())
|
||||
: /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
|
||||
// caching the state because it is used very often
|
||||
const state = new Map()
|
||||
// iterate over all struct readers until we are done
|
||||
while (true) {
|
||||
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 structRefs = clientsStructRefs.get(client)
|
||||
if (structRefs !== undefined) {
|
||||
/**
|
||||
* @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 < ref.id.clock) {
|
||||
if (r.id.clock < stackHead.id.clock) {
|
||||
// put ref with smaller clock on stack instead and continue
|
||||
structRefs.refs[structRefs.i] = ref
|
||||
stack[stack.length - 1] = r
|
||||
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
|
||||
@@ -191,33 +279,45 @@ const resumeStructIntegration = (transaction, store) => {
|
||||
}
|
||||
}
|
||||
// wait until missing struct is available
|
||||
stack.push(stackHead)
|
||||
return
|
||||
}
|
||||
while (m.length > 0) {
|
||||
const missing = m[m.length - 1]
|
||||
if (getState(store, missing.client) <= missing.clock) {
|
||||
const client = missing.client
|
||||
// get the struct reader that has the missing struct
|
||||
const structRefs = clientsStructRefs.get(client)
|
||||
if (structRefs === undefined) {
|
||||
// This update message causally depends on another update message.
|
||||
return
|
||||
}
|
||||
stack.push(structRefs.refs[structRefs.i++])
|
||||
if (structRefs.i === structRefs.refs.length) {
|
||||
clientsStructRefs.delete(client)
|
||||
}
|
||||
break
|
||||
const missing = stackHead.getMissing(transaction, store)
|
||||
if (missing === null) {
|
||||
if (offset === 0 || offset < stackHead.length) {
|
||||
stackHead.integrate(transaction, offset)
|
||||
state.set(stackHead.id.client, stackHead.id.clock + stackHead.length)
|
||||
}
|
||||
ref._missing.pop()
|
||||
}
|
||||
if (m.length === 0) {
|
||||
if (offset < ref.length) {
|
||||
ref.toStruct(transaction, store, offset).integrate(transaction)
|
||||
// iterate to next stackHead
|
||||
if (stack.length > 0) {
|
||||
stackHead = /** @type {GC|Item} */ (stack.pop())
|
||||
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
} else {
|
||||
curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null) {
|
||||
// we are done!
|
||||
break
|
||||
} else {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
}
|
||||
}
|
||||
stack.pop()
|
||||
} 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++]
|
||||
}
|
||||
}
|
||||
store.pendingClientsStructRefs.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +336,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {Transaction} transaction
|
||||
*
|
||||
* @private
|
||||
@@ -246,14 +346,14 @@ export const writeStructsFromTransaction = (encoder, transaction) => writeClient
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {Map<number, Array<GCRef|ItemRef>>} clientsStructsRefs
|
||||
* @param {Map<number, Array<GC|Item>>} clientsStructsRefs
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
const pendingClientsStructRefs = store.pendingClientsStructRefs
|
||||
for (const [client, structRefs] of clientsStructsRefs) {
|
||||
clientsStructsRefs.forEach((structRefs, client) => {
|
||||
const pendingStructRefs = pendingClientsStructRefs.get(client)
|
||||
if (pendingStructRefs === undefined) {
|
||||
pendingClientsStructRefs.set(client, { refs: structRefs, i: 0 })
|
||||
@@ -266,7 +366,22 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,7 +389,7 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
*
|
||||
* This is called when data is received from a remote peer.
|
||||
*
|
||||
* @param {decoding.Decoder} decoder The decoder object to read data from.
|
||||
* @param {AbstractUpdateDecoder} decoder The decoder object to read data from.
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*
|
||||
@@ -282,12 +397,43 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
* @function
|
||||
*/
|
||||
export const readStructs = (decoder, transaction, store) => {
|
||||
const clientsStructRefs = readClientsStructRefs(decoder)
|
||||
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.
|
||||
*
|
||||
* This function has the same effect as `applyUpdate` but accepts an decoder.
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
* @param {AbstractUpdateDecoder} [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)
|
||||
}, transactionOrigin, false)
|
||||
|
||||
/**
|
||||
* Read and apply a document update.
|
||||
*
|
||||
@@ -299,11 +445,24 @@ export const readStructs = (decoder, transaction, store) => {
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
|
||||
transact(ydoc, transaction => {
|
||||
readStructs(decoder, transaction, ydoc.store)
|
||||
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
|
||||
}, transactionOrigin, false)
|
||||
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new DefaultUpdateDecoder(decoder))
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
*
|
||||
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||
*
|
||||
* @param {Doc} ydoc
|
||||
* @param {Uint8Array} update
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
|
||||
const decoder = decoding.createDecoder(update)
|
||||
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
@@ -316,14 +475,13 @@ export const readUpdate = (decoder, ydoc, transactionOrigin) =>
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdate = (ydoc, update, transactionOrigin) =>
|
||||
readUpdate(decoding.createDecoder(update), ydoc, transactionOrigin)
|
||||
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, DefaultUpdateDecoder)
|
||||
|
||||
/**
|
||||
* 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 {encoding.Encoder} encoder
|
||||
* @param {AbstractUpdateEncoder} 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
|
||||
*
|
||||
@@ -342,31 +500,45 @@ 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]
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => {
|
||||
const encoder = encoding.createEncoder()
|
||||
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector, encoder = new UpdateEncoderV2()) => {
|
||||
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
|
||||
writeStateAsUpdate(encoder, doc, targetStateVector)
|
||||
return encoding.toUint8Array(encoder)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new DefaultUpdateEncoder())
|
||||
|
||||
/**
|
||||
* Read state vector from Decoder and return as Map
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {AbstractDSDecoder} decoder
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readStateVector = decoder => {
|
||||
const ss = new Map()
|
||||
const ssLength = decoding.readVarUint(decoder)
|
||||
const ssLength = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const clock = decoding.readVarUint(decoder)
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const clock = decoding.readVarUint(decoder.restDecoder)
|
||||
ss.set(client, clock)
|
||||
}
|
||||
return ss
|
||||
@@ -380,28 +552,34 @@ export const readStateVector = decoder => {
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
|
||||
export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* Write State Vector to `lib0/encoding.js#Encoder`.
|
||||
* Read decodedState and return State as Map.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Uint8Array} decodedState
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const decodeStateVector = decodedState => readStateVector(new DefaultDSDecoder(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* @param {AbstractDSEncoder} encoder
|
||||
* @param {Map<number,number>} sv
|
||||
* @function
|
||||
*/
|
||||
export const writeStateVector = (encoder, sv) => {
|
||||
encoding.writeVarUint(encoder, sv.size)
|
||||
encoding.writeVarUint(encoder.restEncoder, sv.size)
|
||||
sv.forEach((clock, client) => {
|
||||
encoding.writeVarUint(encoder, client)
|
||||
encoding.writeVarUint(encoder, clock)
|
||||
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* Write State Vector to `lib0/encoding.js#Encoder`.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {AbstractDSEncoder} encoder
|
||||
* @param {Doc} doc
|
||||
*
|
||||
* @function
|
||||
@@ -412,12 +590,22 @@ export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encod
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {AbstractDSEncoder} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVector = doc => {
|
||||
const encoder = encoding.createEncoder()
|
||||
export const encodeStateVectorV2 = (doc, encoder = new DSEncoderV2()) => {
|
||||
writeDocumentStateVector(encoder, doc)
|
||||
return encoding.toUint8Array(encoder)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DefaultDSEncoder())
|
||||
|
||||
@@ -16,7 +16,7 @@ export const isParentOf = (parent, child) => {
|
||||
if (child.parent === parent) {
|
||||
return true
|
||||
}
|
||||
child = child.parent._item
|
||||
child = /** @type {AbstractType<any>} */ (child.parent)._item
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
22
src/utils/logging.js
Normal file
22
src/utils/logging.js
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import {
|
||||
AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* Convenient helper to log type information.
|
||||
*
|
||||
* Do not use in productive systems as the output can be immense!
|
||||
*
|
||||
* @param {AbstractType<any>} type
|
||||
*/
|
||||
export const logType = type => {
|
||||
const res = []
|
||||
let n = type._start
|
||||
while (n) {
|
||||
res.push(n)
|
||||
n = n.right
|
||||
}
|
||||
console.log('Children: ', res)
|
||||
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
|
||||
}
|
||||
46
tests/compatibility.tests.js
Normal file
46
tests/compatibility.tests.js
Normal file
File diff suppressed because one or more lines are too long
126
tests/doc.tests.js
Normal file
126
tests/doc.tests.js
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
|
||||
/**
|
||||
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testClientIdDuplicateChange = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.clientID = 0
|
||||
const doc2 = new Y.Doc()
|
||||
doc2.clientID = 0
|
||||
t.assert(doc2.clientID === doc1.clientID)
|
||||
doc1.getArray('a').insert(0, [1, 2])
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.clientID !== doc1.clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetTypeEmptyId = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.getText('').insert(0, 'h')
|
||||
doc1.getText().insert(1, 'i')
|
||||
const doc2 = new Y.Doc()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.getText().toString() === 'hi')
|
||||
t.assert(doc2.getText('').toString() === 'hi')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToJSON = tc => {
|
||||
const doc = new Y.Doc()
|
||||
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
|
||||
|
||||
const arr = doc.getArray('array')
|
||||
arr.push(['test1'])
|
||||
|
||||
const map = doc.getMap('map')
|
||||
map.set('k1', 'v1')
|
||||
const map2 = new Y.Map()
|
||||
map.set('k2', map2)
|
||||
map2.set('m2k1', 'm2v1')
|
||||
|
||||
t.compare(doc.toJSON(), {
|
||||
array: ['test1'],
|
||||
map: {
|
||||
k1: 'v1',
|
||||
k2: {
|
||||
m2k1: 'm2v1'
|
||||
}
|
||||
}
|
||||
}, 'doc.toJSON has array and recursive map')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSubdoc = tc => {
|
||||
const doc = new Y.Doc()
|
||||
doc.load() // doesn't do anything
|
||||
{
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
*/
|
||||
let event = /** @type {any} */ (null)
|
||||
doc.on('subdocs', subdocs => {
|
||||
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
|
||||
})
|
||||
const subdocs = doc.getMap('mysubdocs')
|
||||
const docA = new Y.Doc({ guid: 'a' })
|
||||
docA.load()
|
||||
subdocs.set('a', docA)
|
||||
t.compare(event, [['a'], [], ['a']])
|
||||
|
||||
event = null
|
||||
subdocs.get('a').load()
|
||||
t.assert(event === null)
|
||||
|
||||
event = null
|
||||
subdocs.get('a').destroy()
|
||||
t.compare(event, [['a'], ['a'], []])
|
||||
subdocs.get('a').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
|
||||
subdocs.set('b', new Y.Doc({ guid: 'a' }))
|
||||
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'])
|
||||
}
|
||||
}
|
||||
@@ -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 === 10)
|
||||
t.assert(contentRefs[1] === readContentDeleted)
|
||||
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
|
||||
t.assert(contentRefs[3] === readContentBinary)
|
||||
@@ -25,4 +31,33 @@ export const testStructReferences = tc => {
|
||||
t.assert(contentRefs[6] === readContentFormat)
|
||||
t.assert(contentRefs[7] === readContentType)
|
||||
t.assert(contentRefs[8] === readContentAny)
|
||||
t.assert(contentRefs[9] === readContentDoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
|
||||
import * as array from './y-array.tests.js'
|
||||
import * as map from './y-map.tests.js'
|
||||
import * as array from './y-array.tests.js'
|
||||
import * as text from './y-text.tests.js'
|
||||
import * as xml from './y-xml.tests.js'
|
||||
import * as encoding from './encoding.tests.js'
|
||||
import * as undoredo from './undo-redo.tests.js'
|
||||
import * as compatibility from './compatibility.tests.js'
|
||||
import * as doc from './doc.tests.js'
|
||||
import * as snapshot from './snapshot.tests.js'
|
||||
import * as relativePositions from './relativePositions.tests.js'
|
||||
|
||||
import { runTests } from 'lib0/testing.js'
|
||||
import { isBrowser, isNode } from 'lib0/environment.js'
|
||||
@@ -14,7 +18,7 @@ if (isBrowser) {
|
||||
log.createVConsole(document.body)
|
||||
}
|
||||
runTests({
|
||||
map, array, text, xml, encoding, undoredo
|
||||
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, relativePositions
|
||||
}).then(success => {
|
||||
/* istanbul ignore next */
|
||||
if (isNode) {
|
||||
|
||||
104
tests/relativePositions.tests.js
Normal file
104
tests/relativePositions.tests.js
Normal 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
171
tests/snapshot.tests.js
Normal 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'])
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as Y from '../src/index.js'
|
||||
|
||||
import {
|
||||
createDeleteSetFromStructStore,
|
||||
getStateVector,
|
||||
Item,
|
||||
DeleteItem, DeleteSet, StructStore, Doc // eslint-disable-line
|
||||
} from '../src/internals.js'
|
||||
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as syncProtocol from 'y-protocols/sync.js'
|
||||
import * as object from 'lib0/object.js'
|
||||
import * as Y from '../src/internals.js'
|
||||
export * from '../src/internals.js'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
window.Y = Y // eslint-disable-line
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TestYInstance} y // publish message created by `y` to all other online clients
|
||||
* @param {Uint8Array} m
|
||||
@@ -28,7 +27,7 @@ const broadcastMessage = (y, m) => {
|
||||
}
|
||||
}
|
||||
|
||||
export class TestYInstance extends Doc {
|
||||
export class TestYInstance extends Y.Doc {
|
||||
/**
|
||||
* @param {TestConnector} testConnector
|
||||
* @param {number} clientID
|
||||
@@ -55,6 +54,7 @@ export class TestYInstance extends Doc {
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from TestConnector.
|
||||
*/
|
||||
@@ -62,6 +62,7 @@ export class TestYInstance extends Doc {
|
||||
this.receiving = new Map()
|
||||
this.tc.onlineConns.delete(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append yourself to the list of known Y instances in testconnector.
|
||||
* Also initiate sync with all clients.
|
||||
@@ -83,6 +84,7 @@ export class TestYInstance extends Doc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a message from another client. This message is only appended to the list of receiving messages.
|
||||
* TestConnector decides when this client actually reads this message.
|
||||
@@ -124,6 +126,7 @@ export class TestConnector {
|
||||
*/
|
||||
this.prng = gen
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Y instance and add it to the list of connections
|
||||
* @param {number} clientID
|
||||
@@ -131,6 +134,7 @@ export class TestConnector {
|
||||
createY (clientID) {
|
||||
return new TestYInstance(this, clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose random connection and flush a random message from a random sender.
|
||||
*
|
||||
@@ -162,6 +166,7 @@ export class TestConnector {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} True iff this function actually flushed something
|
||||
*/
|
||||
@@ -172,16 +177,20 @@ export class TestConnector {
|
||||
}
|
||||
return didSomething
|
||||
}
|
||||
|
||||
reconnectAll () {
|
||||
this.allConns.forEach(conn => conn.connect())
|
||||
}
|
||||
|
||||
disconnectAll () {
|
||||
this.allConns.forEach(conn => conn.disconnect())
|
||||
}
|
||||
|
||||
syncAll () {
|
||||
this.reconnectAll()
|
||||
this.flushAllMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to disconnect a randon connection.
|
||||
*/
|
||||
@@ -192,6 +201,7 @@ export class TestConnector {
|
||||
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to reconnect a random connection.
|
||||
*/
|
||||
@@ -218,7 +228,7 @@ export class TestConnector {
|
||||
* @param {t.TestCase} tc
|
||||
* @param {{users?:number}} conf
|
||||
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
|
||||
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.YArray<any>,array1:Y.YArray<any>,array2:Y.YArray<any>,map0:Y.YMap<any>,map1:Y.YMap<any>,map2:Y.YMap<any>,map3:Y.YMap<any>,text0:Y.YText,text1:Y.YText,text2:Y.YText,xml0:Y.YXmlElement,xml1:Y.YXmlElement,xml2:Y.YXmlElement}}
|
||||
*/
|
||||
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
/**
|
||||
@@ -228,19 +238,27 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
users: []
|
||||
}
|
||||
const gen = tc.prng
|
||||
// choose an encoding approach at random
|
||||
if (prng.bool(gen)) {
|
||||
Y.useV2Encoding()
|
||||
} else {
|
||||
Y.useV1Encoding()
|
||||
}
|
||||
|
||||
const testConnector = new TestConnector(gen)
|
||||
result.testConnector = testConnector
|
||||
for (let i = 0; i < users; i++) {
|
||||
const y = testConnector.createY(i)
|
||||
y.clientID = i
|
||||
result.users.push(y)
|
||||
result['array' + i] = y.get('array', Y.Array)
|
||||
result['map' + i] = y.get('map', Y.Map)
|
||||
result['xml' + i] = y.get('xml', Y.XmlElement)
|
||||
result['text' + i] = y.get('text', Y.Text)
|
||||
result['array' + i] = y.getArray('array')
|
||||
result['map' + i] = y.getMap('map')
|
||||
result['xml' + i] = y.get('xml', Y.YXmlElement)
|
||||
result['text' + i] = y.getText('text')
|
||||
}
|
||||
testConnector.syncAll()
|
||||
result.testObjects = result.users.map(initTestObject || (() => null))
|
||||
Y.useV1Encoding()
|
||||
return /** @type {any} */ (result)
|
||||
}
|
||||
|
||||
@@ -258,7 +276,7 @@ export const compare = users => {
|
||||
while (users[0].tc.flushAllMessages()) {}
|
||||
const userArrayValues = users.map(u => u.getArray('array').toJSON())
|
||||
const userMapValues = users.map(u => u.getMap('map').toJSON())
|
||||
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
|
||||
const 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)
|
||||
@@ -270,12 +288,12 @@ export const compare = users => {
|
||||
// Test Map iterator
|
||||
const ymapkeys = Array.from(users[0].getMap('map').keys())
|
||||
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
|
||||
ymapkeys.forEach(key => t.assert(userMapValues[0].hasOwnProperty(key)))
|
||||
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
const mapRes = {}
|
||||
for (let [k, v] of users[0].getMap('map')) {
|
||||
for (const [k, v] of users[0].getMap('map')) {
|
||||
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
|
||||
}
|
||||
t.compare(userMapValues[0], mapRes)
|
||||
@@ -287,23 +305,23 @@ export const compare = users => {
|
||||
t.compare(userXmlValues[i], userXmlValues[i + 1])
|
||||
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
|
||||
t.compare(userTextValues[i], userTextValues[i + 1])
|
||||
t.compare(getStateVector(users[i].store), getStateVector(users[i + 1].store))
|
||||
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
|
||||
t.compare(Y.getStateVector(users[i].store), Y.getStateVector(users[i + 1].store))
|
||||
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
|
||||
compareStructStores(users[i].store, users[i + 1].store)
|
||||
}
|
||||
users.map(u => u.destroy())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item?} a
|
||||
* @param {Item?} b
|
||||
* @param {Y.Item?} a
|
||||
* @param {Y.Item?} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
|
||||
|
||||
/**
|
||||
* @param {StructStore} ss1
|
||||
* @param {StructStore} ss2
|
||||
* @param {Y.StructStore} ss1
|
||||
* @param {Y.StructStore} ss2
|
||||
*/
|
||||
export const compareStructStores = (ss1, ss2) => {
|
||||
t.assert(ss1.clients.size === ss2.clients.size)
|
||||
@@ -318,13 +336,14 @@ export const compareStructStores = (ss1, ss2) => {
|
||||
s1.constructor !== s2.constructor ||
|
||||
!Y.compareIDs(s1.id, s2.id) ||
|
||||
s1.deleted !== s2.deleted ||
|
||||
// @ts-ignore
|
||||
s1.length !== s2.length
|
||||
) {
|
||||
t.fail('Structs dont match')
|
||||
}
|
||||
if (s1 instanceof Item) {
|
||||
if (s1 instanceof Y.Item) {
|
||||
if (
|
||||
!(s2 instanceof Item) ||
|
||||
!(s2 instanceof Y.Item) ||
|
||||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
|
||||
!compareItemIDs(s1.right, s2.right) ||
|
||||
!Y.compareIDs(s1.origin, s2.origin) ||
|
||||
@@ -344,13 +363,13 @@ export const compareStructStores = (ss1, ss2) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds1
|
||||
* @param {DeleteSet} ds2
|
||||
* @param {Y.DeleteSet} ds1
|
||||
* @param {Y.DeleteSet} ds2
|
||||
*/
|
||||
export const compareDS = (ds1, ds2) => {
|
||||
t.assert(ds1.clients.size === ds2.clients.size)
|
||||
for (const [client, deleteItems1] of ds1.clients) {
|
||||
const deleteItems2 = /** @type {Array<DeleteItem>} */ (ds2.clients.get(client))
|
||||
ds1.clients.forEach((deleteItems1, client) => {
|
||||
const deleteItems2 = /** @type {Array<Y.DeleteItem>} */ (ds2.clients.get(client))
|
||||
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
|
||||
for (let i = 0; i < deleteItems1.length; i++) {
|
||||
const di1 = deleteItems1[i]
|
||||
@@ -359,7 +378,7 @@ export const compareDS = (ds1, ds2) => {
|
||||
t.fail('DeleteSets dont match')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,21 +400,21 @@ export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
|
||||
const result = init(tc, { users: 5 }, initTestObject)
|
||||
const { testConnector, users } = result
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (prng.int31(gen, 0, 100) <= 2) {
|
||||
if (prng.int32(gen, 0, 100) <= 2) {
|
||||
// 2% chance to disconnect/reconnect a random user
|
||||
if (prng.bool(gen)) {
|
||||
testConnector.disconnectRandom()
|
||||
} else {
|
||||
testConnector.reconnectRandom()
|
||||
}
|
||||
} else if (prng.int31(gen, 0, 100) <= 1) {
|
||||
} else if (prng.int32(gen, 0, 100) <= 1) {
|
||||
// 1% chance to flush all
|
||||
testConnector.flushAllMessages()
|
||||
} else if (prng.int31(gen, 0, 100) <= 50) {
|
||||
} else if (prng.int32(gen, 0, 100) <= 50) {
|
||||
// 50% chance to flush a random message
|
||||
testConnector.flushRandomMessage()
|
||||
}
|
||||
const user = prng.int31(gen, 0, users.length - 1)
|
||||
const user = prng.int32(gen, 0, users.length - 1)
|
||||
const test = prng.oneOf(gen, mods)
|
||||
test(users[user], gen, result.testObjects[user])
|
||||
}
|
||||
|
||||
@@ -13,6 +13,23 @@ import * as t from 'lib0/testing.js'
|
||||
export const testUndoText = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
|
||||
// items that are added & deleted in the same transaction won't be undo
|
||||
text0.insert(0, 'test')
|
||||
text0.delete(0, 4)
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
// follow redone items
|
||||
text0.insert(0, 'a')
|
||||
undoManager.stopCapturing()
|
||||
text0.delete(0, 1)
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'a')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
text0.insert(0, 'abc')
|
||||
text1.insert(0, 'xyz')
|
||||
testConnector.syncAll()
|
||||
@@ -36,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
|
||||
*/
|
||||
@@ -52,11 +91,11 @@ export const testUndoMap = tc => {
|
||||
const subType = new Y.Map()
|
||||
map0.set('a', subType)
|
||||
subType.set('x', 42)
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ 'a': { x: 42 } }))
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('a') === 1)
|
||||
undoManager.redo()
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ 'a': { x: 42 } }))
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
|
||||
testConnector.syncAll()
|
||||
// if content is overwritten by another user, undo operations should be skipped
|
||||
map1.set('a', 44)
|
||||
@@ -65,6 +104,15 @@ export const testUndoMap = tc => {
|
||||
t.assert(map0.get('a') === 44)
|
||||
undoManager.redo()
|
||||
t.assert(map0.get('a') === 44)
|
||||
|
||||
// test setting value multiple times
|
||||
map0.set('b', 'initial')
|
||||
undoManager.stopCapturing()
|
||||
map0.set('b', 'val1')
|
||||
map0.set('b', 'val2')
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('b') === 'initial')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,33 @@ import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBasicUpdate = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc1.getArray('array').insert(0, ['hi'])
|
||||
const update = Y.encodeStateAsUpdate(doc1)
|
||||
Y.applyUpdate(doc2, update)
|
||||
t.compare(doc2.getArray('array').toArray(), ['hi'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSlice = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const arr = doc1.getArray('array')
|
||||
arr.insert(0, [1, 2, 3])
|
||||
t.compareArrays(arr.slice(0), [1, 2, 3])
|
||||
t.compareArrays(arr.slice(1), [2, 3])
|
||||
t.compareArrays(arr.slice(0, -1), [1, 2])
|
||||
arr.insert(0, [0])
|
||||
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
|
||||
t.compareArrays(arr.slice(0, 2), [0, 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -192,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
|
||||
*/
|
||||
@@ -207,7 +262,7 @@ export const testChangeEvent = tc => {
|
||||
const newArr = new Y.Array()
|
||||
array0.insert(0, [newArr, 4, 'dtrn'])
|
||||
t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0)
|
||||
t.compare(changes.delta, [{insert: [newArr, 4, 'dtrn']}])
|
||||
t.compare(changes.delta, [{ insert: [newArr, 4, 'dtrn'] }])
|
||||
changes = null
|
||||
array0.delete(0, 2)
|
||||
t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2)
|
||||
@@ -227,7 +282,7 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
|
||||
/**
|
||||
* @type {Array<Object<string,any>>}
|
||||
*/
|
||||
let events = []
|
||||
const events = []
|
||||
array0.observe(e => {
|
||||
events.push(e)
|
||||
})
|
||||
@@ -318,7 +373,7 @@ export const testIteratingArrayContainingTypes = tc => {
|
||||
arr.push([map])
|
||||
}
|
||||
let cnt = 0
|
||||
for (let item of arr) {
|
||||
for (const item of arr) {
|
||||
t.assert(item.get('value') === cnt++, 'value is correct')
|
||||
}
|
||||
y.destroy()
|
||||
@@ -335,23 +390,26 @@ const arrayTransactions = [
|
||||
const yarray = user.getArray('array')
|
||||
var uniqueNumber = getUniqueNumber()
|
||||
var content = []
|
||||
var len = prng.int31(gen, 1, 4)
|
||||
var len = prng.int32(gen, 1, 4)
|
||||
for (var i = 0; i < len; i++) {
|
||||
content.push(uniqueNumber)
|
||||
}
|
||||
var pos = prng.int31(gen, 0, yarray.length)
|
||||
var pos = prng.int32(gen, 0, yarray.length)
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.insert(pos, content)
|
||||
oldContent.splice(pos, 0, ...content)
|
||||
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
|
||||
},
|
||||
function insertTypeArray (user, gen) {
|
||||
const yarray = user.getArray('array')
|
||||
var pos = prng.int31(gen, 0, yarray.length)
|
||||
var pos = prng.int32(gen, 0, yarray.length)
|
||||
yarray.insert(pos, [new Y.Array()])
|
||||
var array2 = yarray.get(pos)
|
||||
array2.insert(0, [1, 2, 3, 4])
|
||||
},
|
||||
function insertTypeMap (user, gen) {
|
||||
const yarray = user.getArray('array')
|
||||
var pos = prng.int31(gen, 0, yarray.length)
|
||||
var pos = prng.int32(gen, 0, yarray.length)
|
||||
yarray.insert(pos, [new Y.Map()])
|
||||
var map = yarray.get(pos)
|
||||
map.set('someprop', 42)
|
||||
@@ -362,17 +420,20 @@ const arrayTransactions = [
|
||||
const yarray = user.getArray('array')
|
||||
var length = yarray.length
|
||||
if (length > 0) {
|
||||
var somePos = prng.int31(gen, 0, length - 1)
|
||||
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
|
||||
var somePos = prng.int32(gen, 0, length - 1)
|
||||
var delLength = prng.int32(gen, 1, math.min(2, length - somePos))
|
||||
if (prng.bool(gen)) {
|
||||
var type = yarray.get(somePos)
|
||||
if (type.length > 0) {
|
||||
somePos = prng.int31(gen, 0, type.length - 1)
|
||||
delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
|
||||
somePos = prng.int32(gen, 0, type.length - 1)
|
||||
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
|
||||
type.delete(somePos, delLength)
|
||||
}
|
||||
} else {
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.delete(somePos, delLength)
|
||||
oldContent.splice(somePos, delLength)
|
||||
t.compareArrays(yarray.toArray(), oldContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,8 +442,8 @@ const arrayTransactions = [
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests4 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 4)
|
||||
export const testRepeatGeneratingYarrayTests6 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 6)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,33 @@ import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testMapHavingIterableAsConstructorParamTests = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
|
||||
const m1 = new Y.Map(Object.entries({ number: 1, string: 'hello' }))
|
||||
map0.set('m1', m1)
|
||||
t.assert(m1.get('number') === 1)
|
||||
t.assert(m1.get('string') === 'hello')
|
||||
|
||||
const m2 = new Y.Map([
|
||||
['object', { x: 1 }],
|
||||
['boolean', true]
|
||||
])
|
||||
map0.set('m2', m2)
|
||||
t.assert(m2.get('object').x === 1)
|
||||
t.assert(m2.get('boolean') === true)
|
||||
|
||||
const m3 = new Y.Map([...m1, ...m2])
|
||||
map0.set('m3', m3)
|
||||
t.assert(m3.get('number') === 1)
|
||||
t.assert(m3.get('string') === 'hello')
|
||||
t.assert(m3.get('object').x === 1)
|
||||
t.assert(m3.get('boolean') === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -33,6 +60,7 @@ export const testBasicMapTests = tc => {
|
||||
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
|
||||
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
|
||||
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
|
||||
t.assert(map0.size === 6, 'client 0 map has correct size')
|
||||
|
||||
users[2].connect()
|
||||
testConnector.flushAllMessages()
|
||||
@@ -43,6 +71,7 @@ export const testBasicMapTests = tc => {
|
||||
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
|
||||
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
|
||||
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
|
||||
t.assert(map1.size === 6, 'client 1 map has correct size')
|
||||
|
||||
// compare disconnected user
|
||||
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
|
||||
@@ -66,7 +95,7 @@ export const testGetAndSetOfMapProperty = tc => {
|
||||
|
||||
testConnector.flushAllMessages()
|
||||
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
const u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'stuffy')
|
||||
t.assert(u.get('undefined') === undefined, 'undefined')
|
||||
@@ -108,7 +137,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
|
||||
map0.set('stuff', 'stuffy')
|
||||
t.compare(map0.get('stuff'), 'stuffy')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'stuffy')
|
||||
}
|
||||
@@ -123,13 +152,27 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
|
||||
map0.set('stuff', 'c0')
|
||||
map1.set('stuff', 'c1')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'c1')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSizeAndDeleteOfMapProperty = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
map0.set('stuff', 'c0')
|
||||
map0.set('otherstuff', 'c1')
|
||||
t.assert(map0.size === 2, `map size is ${map0.size} expected 2`)
|
||||
map0.delete('stuff')
|
||||
t.assert(map0.size === 1, `map size after delete is ${map0.size}, expected 1`)
|
||||
map0.delete('otherstuff')
|
||||
t.assert(map0.size === 0, `map size after delete is ${map0.size}, expected 0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -139,7 +182,7 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
|
||||
map1.set('stuff', 'c1')
|
||||
map1.delete('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.assert(u.get('stuff') === undefined)
|
||||
}
|
||||
@@ -156,7 +199,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
|
||||
map1.set('stuff', 'c2')
|
||||
map2.set('stuff', 'c3')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'c3')
|
||||
}
|
||||
@@ -179,7 +222,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
|
||||
map3.set('stuff', 'c3')
|
||||
map3.delete('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.assert(u.get('stuff') === undefined)
|
||||
}
|
||||
@@ -340,6 +383,56 @@ export const testChangeEvent = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const map = doc.getMap('map')
|
||||
|
||||
let updateCalled = false
|
||||
let throwingObserverCalled = false
|
||||
let throwingDeepObserverCalled = false
|
||||
doc.on('update', () => {
|
||||
updateCalled = true
|
||||
})
|
||||
|
||||
const throwingObserver = () => {
|
||||
throwingObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
const throwingDeepObserver = () => {
|
||||
throwingDeepObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
map.observe(throwingObserver)
|
||||
map.observeDeep(throwingDeepObserver)
|
||||
|
||||
t.fails(() => {
|
||||
map.set('y', '2')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
// check if it works again
|
||||
updateCalled = false
|
||||
throwingObserverCalled = false
|
||||
throwingDeepObserverCalled = false
|
||||
t.fails(() => {
|
||||
map.set('z', '3')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
t.assert(map.get('z') === '3')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -380,12 +473,12 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
|
||||
*/
|
||||
const mapTransactions = [
|
||||
function set (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
var value = prng.utf16String(gen)
|
||||
user.getMap('map').set(key, value)
|
||||
},
|
||||
function setType (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
|
||||
user.getMap('map').set(key, type)
|
||||
if (type instanceof Y.Array) {
|
||||
@@ -395,7 +488,7 @@ const mapTransactions = [
|
||||
}
|
||||
},
|
||||
function _delete (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
user.getMap('map').delete(key)
|
||||
}
|
||||
]
|
||||
@@ -404,7 +497,7 @@ const mapTransactions = [
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests10 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 10)
|
||||
applyRandomTests(tc, mapTransactions, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as Y from './testHelper.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
const { init, compare } = Y
|
||||
|
||||
/**
|
||||
@@ -75,16 +78,39 @@ 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 testGetDeltaWithEmbeds = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.applyDelta([{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
t.compare(text0.toDelta(), [{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -127,7 +153,7 @@ export const testSnapshot = tc => {
|
||||
delete v.attributes.ychange.user
|
||||
}
|
||||
})
|
||||
t.compare(state2Diff, [{insert: 'a'}, {insert: 'x', attributes: {ychange: { type: 'added' }}}, {insert: 'b', attributes: {ychange: { type: 'removed' }}}, { insert: 'cd' }])
|
||||
t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,3 +175,472 @@ export const testSnapshotDeleteAfter = tc => {
|
||||
const state1 = text0.toDelta(snapshot1)
|
||||
t.compare(state1, [{ insert: 'abcd' }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToJson = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'abc', { bold: true })
|
||||
t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' }, { width: 100 })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' }, attributes: { width: 100 } }, { insert: 'b', attributes: { bold: true } }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedNoAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemoved = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.delete(0, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemovedInMidText = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, '1234')
|
||||
text0.insert(2, 'ab', { bold: true })
|
||||
text0.delete(2, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertAndDeleteAtRandomPositions = tc => {
|
||||
const N = 100000
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
const gen = tc.prng
|
||||
|
||||
// create initial content
|
||||
// let expectedResult = init
|
||||
text0.insert(0, prng.word(gen, N / 2, N / 2))
|
||||
|
||||
// apply changes
|
||||
for (let i = 0; i < N; i++) {
|
||||
const pos = prng.uint32(gen, 0, text0.length)
|
||||
if (prng.bool(gen)) {
|
||||
const len = prng.uint32(gen, 1, 5)
|
||||
const word = prng.word(gen, 0, len)
|
||||
text0.insert(pos, word)
|
||||
// expectedResult = expectedResult.slice(0, pos) + word + expectedResult.slice(pos)
|
||||
} else {
|
||||
const len = prng.uint32(gen, 0, math.min(3, text0.length - pos))
|
||||
text0.delete(pos, len)
|
||||
// expectedResult = expectedResult.slice(0, pos) + expectedResult.slice(pos + len)
|
||||
}
|
||||
}
|
||||
// t.compareStrings(text0.toString(), expectedResult)
|
||||
t.describe('final length', '' + text0.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testAppendChars = tc => {
|
||||
const N = 10000
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
|
||||
// apply changes
|
||||
for (let i = 0; i < N; i++) {
|
||||
text0.insert(text0.length, 'a')
|
||||
}
|
||||
t.assert(text0.length === N)
|
||||
}
|
||||
|
||||
const largeDocumentSize = 100000
|
||||
|
||||
const id = Y.createID(0, 0)
|
||||
const c = new Y.ContentString('a')
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBestCase = tc => {
|
||||
const N = largeDocumentSize
|
||||
const items = new Array(N)
|
||||
t.measureTime('time to create two million items in the best case', () => {
|
||||
const parent = /** @type {any} */ ({})
|
||||
let prevItem = null
|
||||
for (let i = 0; i < N; i++) {
|
||||
/**
|
||||
* @type {Y.Item}
|
||||
*/
|
||||
const n = new Y.Item(Y.createID(0, 0), null, null, null, null, null, null, c)
|
||||
// items.push(n)
|
||||
items[i] = n
|
||||
n.right = prevItem
|
||||
n.rightOrigin = prevItem ? id : null
|
||||
n.content = c
|
||||
n.parent = parent
|
||||
prevItem = n
|
||||
}
|
||||
})
|
||||
const newArray = new Array(N)
|
||||
t.measureTime('time to copy two million items to new Array', () => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
newArray[i] = items[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tryGc = () => {
|
||||
// @ts-ignore
|
||||
if (typeof global !== 'undefined' && global.gc) {
|
||||
// @ts-ignore
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testLargeFragmentedDocument = tc => {
|
||||
const itemsToInsert = largeDocumentSize
|
||||
let update = /** @type {any} */ (null)
|
||||
;(() => {
|
||||
const doc1 = new Y.Doc()
|
||||
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 encode document', () => {
|
||||
update = Y.encodeStateAsUpdateV2(doc1)
|
||||
})
|
||||
t.describe('Document size:', update.byteLength)
|
||||
})()
|
||||
;(() => {
|
||||
const doc2 = new Y.Doc()
|
||||
tryGc()
|
||||
t.measureTime(`time to apply ${itemsToInsert} updates`, () => {
|
||||
Y.applyUpdateV2(doc2, 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
|
||||
|
||||
/**
|
||||
* Random tests for pure text operations without formatting.
|
||||
*
|
||||
* @type Array<function(any,prng.PRNG):void>
|
||||
*/
|
||||
const textChanges = [
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert text
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
const prevText = ytext.toString()
|
||||
ytext.insert(insertPos, text)
|
||||
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + text + prevText.slice(insertPos))
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // delete text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
const prevText = ytext.toString()
|
||||
ytext.delete(insertPos, overwrite)
|
||||
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + prevText.slice(insertPos + overwrite))
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges5 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 5))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges30 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 30))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges40 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 40))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges50 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 50))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges70 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 70))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges90 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 90))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges300 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 300))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
const marks = [
|
||||
{ bold: true },
|
||||
{ italic: true },
|
||||
{ italic: true, color: '#888' }
|
||||
]
|
||||
|
||||
const marksChoices = [
|
||||
undefined,
|
||||
...marks
|
||||
]
|
||||
|
||||
/**
|
||||
* Random tests for all features of y-text (formatting, embeds, ..).
|
||||
*
|
||||
* @type Array<function(any,prng.PRNG):void>
|
||||
*/
|
||||
const qChanges = [
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert text
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
const attrs = prng.oneOf(gen, marksChoices)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
ytext.insert(insertPos, text, attrs)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert embed
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // delete text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
ytext.delete(insertPos, overwrite)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // format text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
const format = prng.oneOf(gen, marks)
|
||||
ytext.format(insertPos, overwrite, format)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert codeblock
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.toString().length)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
const ops = []
|
||||
if (insertPos > 0) {
|
||||
ops.push({ retain: insertPos })
|
||||
}
|
||||
ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } })
|
||||
ytext.applyDelta(ops)
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {any} result
|
||||
*/
|
||||
const checkResult = result => {
|
||||
for (let i = 1; i < result.testObjects.length; i++) {
|
||||
const p1 = result.users[i].getText('text').toDelta()
|
||||
const p2 = result.users[i].getText('text').toDelta()
|
||||
t.compare(p1, p2)
|
||||
}
|
||||
// Uncomment this to find formatting-cleanup issues
|
||||
// const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text'))
|
||||
// t.assert(cleanups === 0)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges1 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2Repeat = tc => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges3 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges30 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 30))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges40 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 40))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges70 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 70))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges100 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 100))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges300 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 300))
|
||||
}
|
||||
|
||||
@@ -60,16 +60,76 @@ export const testEvents = tc => {
|
||||
*/
|
||||
export const testTreewalker = tc => {
|
||||
const { users, xml0 } = init(tc, { users: 3 })
|
||||
let paragraph1 = new Y.XmlElement('p')
|
||||
let paragraph2 = new Y.XmlElement('p')
|
||||
let text1 = new Y.XmlText('init')
|
||||
let text2 = new Y.XmlText('text')
|
||||
const paragraph1 = new Y.XmlElement('p')
|
||||
const paragraph2 = new Y.XmlElement('p')
|
||||
const text1 = new Y.XmlText('init')
|
||||
const text2 = new Y.XmlText('text')
|
||||
paragraph1.insert(0, [text1, text2])
|
||||
xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')])
|
||||
let allParagraphs = xml0.querySelectorAll('p')
|
||||
const allParagraphs = xml0.querySelectorAll('p')
|
||||
t.assert(allParagraphs.length === 2, 'found exactly two paragraphs')
|
||||
t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1')
|
||||
t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2')
|
||||
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()])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
"allowJs": true, /* Allow javascript files to be compiled. */
|
||||
"checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
// "outDir": "./build", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "outFile": "./dist/yjs.js", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true, /* Do not emit outputs. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
@@ -22,6 +22,7 @@
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"emitDeclarationOnly": true,
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
@@ -56,9 +57,8 @@
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"maxNodeModuleJsDepth": 5,
|
||||
// "maxNodeModuleJsDepth": 0,
|
||||
// "types": ["./src/utils/typedefs.js"]
|
||||
},
|
||||
"include": ["./src/**/*", "./tests/**/*"],
|
||||
"exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
|
||||
"include": ["./src/**/*.js", "./tests/**/*.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user