Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e2710ded9 | ||
|
|
227018f5c7 | ||
|
|
da8bacfc78 | ||
|
|
92bad63145 | ||
|
|
52ff230dd1 | ||
|
|
fe48efe64f | ||
|
|
7e40fc442d | ||
|
|
035e350062 | ||
|
|
bf338d8040 | ||
|
|
658c520b93 | ||
|
|
2576d4efca | ||
|
|
58b754950e | ||
|
|
ea7ad07f34 | ||
|
|
1c999b250e | ||
|
|
e9189365ee | ||
|
|
e0a2f11db3 | ||
|
|
7445a9ce5f | ||
|
|
7f6c12a541 | ||
|
|
370d0c138d | ||
|
|
d29de75f85 | ||
|
|
f215866429 | ||
|
|
093b41ccc4 | ||
|
|
ab60cd1ff8 | ||
|
|
1130abe05b | ||
|
|
31b4ab8d0c | ||
|
|
ab978b2003 | ||
|
|
afc6728c9e | ||
|
|
0ef5bd42fe | ||
|
|
3ece681758 | ||
|
|
cac9407185 | ||
|
|
7ea8ffebae | ||
|
|
d7751c16fd | ||
|
|
a64c51ec06 | ||
|
|
7405057037 | ||
|
|
6208b82872 | ||
|
|
12a9134b09 | ||
|
|
7395229086 | ||
|
|
8fb73edd97 | ||
|
|
f1ad5686c1 | ||
|
|
ed9236bdc7 | ||
|
|
5405fd2d7c | ||
|
|
12667f6b66 | ||
|
|
3d7ef7e28b | ||
|
|
56267e0a7d | ||
|
|
da71f6fa45 | ||
|
|
588788fbef | ||
|
|
fb9df6efe2 | ||
|
|
a69ecb0287 | ||
|
|
923fc6e06e | ||
|
|
0fdfd93e4b | ||
|
|
e0e5f8d2ea | ||
|
|
daf034cf75 | ||
|
|
2157ebb4d0 | ||
|
|
97ef4ae1e0 | ||
|
|
df2d59e2fb | ||
|
|
7a61c90261 | ||
|
|
6fa8778fc7 | ||
|
|
6b7b3136e0 | ||
|
|
da052bdb0a | ||
|
|
1bc9308566 | ||
|
|
a5e0448a92 | ||
|
|
c0c2b3347b | ||
|
|
6258ba1ce9 | ||
|
|
5a7ee74f68 | ||
|
|
29fb4a0aab | ||
|
|
8937494bdd | ||
|
|
4504196d5c | ||
|
|
0c8d29bfff | ||
|
|
43384e4148 | ||
|
|
a2b62b0a58 | ||
|
|
6febf51b1a | ||
|
|
5a4816a1b2 | ||
|
|
4ad8af9a80 | ||
|
|
fc25136b25 | ||
|
|
ece1fe5426 | ||
|
|
40196ae0a3 | ||
|
|
bdefe0526d | ||
|
|
dbbb86adc7 | ||
|
|
1c9c97ffe6 | ||
|
|
14c14de21e | ||
|
|
71fad52854 | ||
|
|
3935ba1faa | ||
|
|
4aacb487d2 | ||
|
|
5f56baa23e | ||
|
|
8d809ebacb | ||
|
|
92624afbff | ||
|
|
1e8efd5104 | ||
|
|
7b680f1bda | ||
|
|
806bf3f6dd | ||
|
|
42fe19daf1 | ||
|
|
7d3de7fa07 | ||
|
|
63c1cb4eb9 | ||
|
|
be1449a7af | ||
|
|
a22b3cdbc1 | ||
|
|
e9a0dc4ed2 | ||
|
|
b0b276d964 | ||
|
|
d3e117702c | ||
|
|
ff5067e149 | ||
|
|
f80e39a477 | ||
|
|
f70198333a | ||
|
|
3c31b22a92 | ||
|
|
6b8cef29e2 | ||
|
|
4a06492fb1 | ||
|
|
46fbce0de8 | ||
|
|
239703fe5c | ||
|
|
5e907e3281 | ||
|
|
6aea35246b | ||
|
|
5058189a46 | ||
|
|
4db3439bb1 | ||
|
|
aa5463b06d | ||
|
|
afe8e52840 | ||
|
|
d0f9c4a27f | ||
|
|
a5ffdce342 | ||
|
|
67d27dfca2 | ||
|
|
9f1548204a | ||
|
|
46e108f345 | ||
|
|
bda622f523 | ||
|
|
fef9e39d91 | ||
|
|
5751a12c11 | ||
|
|
fddb620d41 | ||
|
|
abf3fab1b6 | ||
|
|
69e2375dc5 | ||
|
|
058a50285c | ||
|
|
8678ef62d6 | ||
|
|
db53b6c720 | ||
|
|
3f34777201 | ||
|
|
24eddb2d75 | ||
|
|
8ce107bd17 | ||
|
|
2d1e3fde43 | ||
|
|
04009f0d42 | ||
|
|
d69d93f812 | ||
|
|
931a37a331 | ||
|
|
0ec2753313 | ||
|
|
8fd1f3405a | ||
|
|
f577a8e3cf | ||
|
|
84e95f11cb | ||
|
|
f08682ddfd | ||
|
|
c20d72b886 | ||
|
|
c9414f51a7 | ||
|
|
0fee9dfff4 | ||
|
|
4cfa49d601 | ||
|
|
b6562f3e80 | ||
|
|
164b38f0cd | ||
|
|
99326f67b8 | ||
|
|
1c360f9f59 | ||
|
|
8f0d7cdfc2 | ||
|
|
b281277c67 | ||
|
|
532d5fccb2 | ||
|
|
8f421a0f42 | ||
|
|
8fec835338 | ||
|
|
81a36a2762 | ||
|
|
6403bc2bb5 | ||
|
|
20e1234af2 | ||
|
|
3aebb8db83 | ||
|
|
51bb732606 | ||
|
|
f857345451 | ||
|
|
645f05b0bb | ||
|
|
1cf709093c | ||
|
|
9569d3e297 | ||
|
|
507edccdf8 | ||
|
|
9914f48a52 | ||
|
|
d57629b36d | ||
|
|
294ba351b6 | ||
|
|
610e532868 | ||
|
|
f73fb4796b | ||
|
|
32d391d7ab | ||
|
|
28e1b19e57 | ||
|
|
e90d9de5ed | ||
|
|
9a7250f192 | ||
|
|
4154b12f14 | ||
|
|
9df5016667 | ||
|
|
1becaccdd9 | ||
|
|
ea4e9a0007 | ||
|
|
a4e48d1ddf | ||
|
|
0a39a92b33 | ||
|
|
bd819243eb | ||
|
|
2ec19defcb |
@@ -1,7 +0,0 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@3.0.0
|
||||
workflows:
|
||||
node-tests:
|
||||
jobs:
|
||||
- node/test
|
||||
@@ -66,7 +66,7 @@ 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
|
||||
the `Item#integrate()` method. The YATA paper has much more detail on this
|
||||
algorithm.
|
||||
|
||||
### Item Storage
|
||||
@@ -152,8 +152,8 @@ concepts that can be used to create a custom network protocol:
|
||||
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
|
||||
* `state vector`: A state vector defines the known state of each user (a set of
|
||||
tuples `(client, clock)`). This object is also efficiently encoded as a
|
||||
Uint8Array.
|
||||
|
||||
The client can ask a remote client for missing document updates by sending
|
||||
@@ -168,7 +168,7 @@ An implementation of the syncing process is in
|
||||
## 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
|
||||
\+ `delete set`. A client can restore an old document state by iterating through
|
||||
the sequence CRDT and ignoring all Items that have an `id.clock >
|
||||
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
|
||||
use the delete set to find out if an item was deleted or not.
|
||||
|
||||
203
README.md
203
README.md
@@ -15,60 +15,51 @@ 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)
|
||||
* Chat: [Gitter](https://gitter.im/Yjs/community) | [Discord](https://discord.gg/T3nqMT6qbM)
|
||||
* 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/)
|
||||
|
||||
: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).
|
||||
:construction_worker_woman: If you are looking for professional support, please
|
||||
consider supporting this project via a "support contract" on
|
||||
[GitHub Sponsors](https://github.com/sponsors/dmonad). I will attend your issues
|
||||
quicker and we can discuss questions and problems in regular video conferences.
|
||||
Otherwise you can find help on our community [discussion board](https://discuss.yjs.dev).
|
||||
|
||||
## Sponsors
|
||||
## Sponsorship
|
||||
|
||||
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)
|
||||
Please contribute to the project financially - especially if your company relies
|
||||
on Yjs. [](https://github.com/sponsors/dmonad)
|
||||
|
||||
## Who is using Yjs
|
||||
|
||||
* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted
|
||||
collaborative notes app.
|
||||
* [AFFiNE](https://affine.pro/) A local-first, privacy-first, open source
|
||||
knowledge base. 🏅
|
||||
* [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star2:
|
||||
* [Sana](https://sanalabs.com/) A learning platform with collaborative text
|
||||
editing powered by Yjs.
|
||||
* [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and
|
||||
community. :star2:
|
||||
* [Input](https://input.com/) A collaborative note taking app. :star2:
|
||||
community. :star:
|
||||
* [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.
|
||||
Nimbus Web. :star:
|
||||
* [Pluxbox RadioManager](https://getradiomanager.com/) A web-based app to
|
||||
collaboratively organize radio broadcasts. :star:
|
||||
* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted
|
||||
collaborative notes app.
|
||||
* [PRSM](https://prsm.uk/) Collaborative mind-mapping and system visualisation. *[(source)](https://github.com/micrology/prsm)*
|
||||
* [Alldone](https://alldone.app/) A next-gen project management and
|
||||
collaboration platform.
|
||||
* [Living Spec](https://livingspec.com/) A modern way for product teams to collaborate.
|
||||
* [Slidebeamer](https://slidebeamer.com/) Presentation app.
|
||||
* [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys.
|
||||
* [Skiff](https://skiff.org/) Private, decentralized workspace.
|
||||
* [Hyperquery](https://hyperquery.ai/) A collaborative data workspace for
|
||||
sharing analyses, documentation, spreadsheets, and dashboards.
|
||||
* [Nosgestesclimat](https://nosgestesclimat.fr/groupe) The french carbon
|
||||
footprint calculator has a group P2P mode based on yjs
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -101,6 +92,8 @@ are implemented in separate modules.
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
|
||||
| [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) |
|
||||
| [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) |
|
||||
| [immer](https://github.com/immerjs/immer) | | [immer-yjs](https://github.com/sep2/immer-yjs) | [demo](https://codesandbox.io/s/immer-yjs-demo-6e0znb) |
|
||||
| React / Vue / Svelte / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) |
|
||||
|
||||
### Providers
|
||||
|
||||
@@ -129,6 +122,12 @@ leveldb database.
|
||||
Efficiently persists document updates to the browsers indexeddb database.
|
||||
The document is immediately available and only diffs need to be synced through the
|
||||
network provider.
|
||||
</dd>
|
||||
<dt><a href="https://github.com/marcopolo/y-libp2p">y-libp2p</a></dt>
|
||||
<dd>
|
||||
Uses <a href="https://libp2p.io/">libp2p</a> to propagate updates via
|
||||
<a href="https://github.com/libp2p/specs/tree/master/pubsub/gossipsub">GossipSub</a>.
|
||||
Also includes a peer-sync mechanism to catch up on missed updates.
|
||||
</dd>
|
||||
<dt><a href="https://github.com/yjs/y-dat">y-dat</a></dt>
|
||||
<dd>
|
||||
@@ -136,6 +135,20 @@ network provider.
|
||||
<a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has
|
||||
an append-only log of CRDT local updates (hypercore). Multifeed manages and sync
|
||||
hypercores and y-dat listens to changes and applies them to the Yjs document.
|
||||
</dd>
|
||||
<dt><a href="https://github.com/yousefED/matrix-crdt">Matrix-CRDT</a></dt>
|
||||
<dd>
|
||||
Use <a href="https://www.matrix.org">Matrix</a> as an off-the-shelf backend for
|
||||
Yjs by using the <a href="https://github.com/yousefED/matrix-crdt">MatrixProvider</a>.
|
||||
Use Matrix as transport and storage of Yjs updates, so you can focus building
|
||||
your client app and Matrix can provide powerful features like Authentication,
|
||||
Authorization, Federation, hosting (self-hosting or SaaS) and even End-to-End
|
||||
Encryption (E2EE).
|
||||
</dd>
|
||||
<dt><a href="https://github.com/MaxNoetzold/y-mongodb-provider">y-mongodb-provider</a></dt>
|
||||
<dd>
|
||||
Adds persistent storage to a server with MongoDB. Can be used with the
|
||||
y-websocket provider.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -629,6 +642,8 @@ type. Doesn't log types that have not been defined (using
|
||||
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
|
||||
<b><code>getMap(string):Y.Map</code></b>
|
||||
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
|
||||
<b><code>getText(string):Y.Text</code></b>
|
||||
<dd>Define a shared Y.Text type. Is equivalent to <code>y.get(string, Y.Text)</code>.</dd>
|
||||
<b><code>getXmlFragment(string):Y.XmlFragment</code></b>
|
||||
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
|
||||
<b><code>on(string, function)</code></b>
|
||||
@@ -714,7 +729,7 @@ Y.applyUpdate(ydoc1, diff2)
|
||||
Y.applyUpdate(ydoc2, diff1)
|
||||
```
|
||||
|
||||
### Example: Syncing clients without loading the Y.Doc
|
||||
#### Example: Syncing clients without loading the Y.Doc
|
||||
|
||||
It is possible to sync clients and compute delta updates without loading the Yjs
|
||||
document to memory. Yjs exposes an API to compute the differences directly on the
|
||||
@@ -738,6 +753,17 @@ currentState1 = Y.mergeUpdates([currentState1, diff2])
|
||||
currentState1 = Y.mergeUpdates([currentState1, diff1])
|
||||
```
|
||||
|
||||
#### Using V2 update format
|
||||
|
||||
Yjs implements two update formats. By default you are using the V1 update format.
|
||||
You can opt-in into the V2 update format wich provides much better compression.
|
||||
It is not yet used by all providers. However, you can already use it if
|
||||
you are building your own provider. All below functions are available with the
|
||||
suffix "V2". E.g. `Y.applyUpdate` ⇒ `Y.applyUpdateV2`. We also support conversion
|
||||
functions between both formats: `Y.convertUpdateFormatV1ToV2` & `Y.convertUpdateFormatV2ToV1`.
|
||||
|
||||
#### Update API
|
||||
|
||||
<dl>
|
||||
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
|
||||
<dd>
|
||||
@@ -770,10 +796,33 @@ Encode the missing differences to another update message. This function works
|
||||
similarly to <code>Y.encodeStateAsUpdate(ydoc, stateVector)</code> but works
|
||||
on updates instead.
|
||||
</dd>
|
||||
<b><code>convertUpdateFormatV1ToV2</code></b>
|
||||
<dd>
|
||||
Convert V1 update format to the V2 update format.
|
||||
</dd>
|
||||
<b><code>convertUpdateFormatV2ToV1</code></b>
|
||||
<dd>
|
||||
Convert V2 update format to the V1 update format.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
### Relative Positions
|
||||
|
||||
When working with collaborative documents, we often need to work with positions.
|
||||
Positions may represent cursor locations, selection ranges, or even assign a
|
||||
comment to a range of text. Normal index-positions (expressed as integers) are
|
||||
not convenient to use because the index-range is invalidated as soon as a remote
|
||||
change manipulates the document. Relative positions give you a powerful API to
|
||||
express positions.
|
||||
|
||||
A relative position is fixated to an element in the shared document and is not
|
||||
affected by remote changes. I.e. given the document `"a|c"`, the relative
|
||||
position is attached to `c`. When a remote user modifies the document by
|
||||
inserting a character before the cursor, the cursor will stay attached to the
|
||||
character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the relative position is
|
||||
set to the end of the document, it will stay attached to the end of the
|
||||
document.
|
||||
|
||||
#### Example: Transform to RelativePosition and back
|
||||
|
||||
```js
|
||||
@@ -808,14 +857,35 @@ pos.index === 2 // => true
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
|
||||
<dd></dd>
|
||||
<b><code>
|
||||
Y.createRelativePositionFromTypeIndex(type:Uint8Array|Y.Type, index: number
|
||||
[, assoc=0])
|
||||
</code></b>
|
||||
<dd>
|
||||
Create a relative position fixated to the i-th element in any sequence-like
|
||||
shared type (if <code>assoc >= 0</code>). By default, the position associates
|
||||
with the character that comes after the specified index position. If
|
||||
<code>assoc < 0</code>, then the relative position associates with the character
|
||||
before the specified index position.
|
||||
</dd>
|
||||
<b><code>
|
||||
Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc):
|
||||
{ type: Y.AbstractType, index: number, assoc: number } | null
|
||||
</code></b>
|
||||
<dd>
|
||||
Create an absolute position from a relative position. If the relative position
|
||||
cannot be referenced, or the type is deleted, then the result is null.
|
||||
</dd>
|
||||
<b><code>
|
||||
Y.encodeRelativePosition(RelativePosition):Uint8Array
|
||||
</code></b>
|
||||
<dd>
|
||||
Encode a relative position to an Uint8Array. Binary data is the preferred
|
||||
encoding format for document updates. If you prefer JSON encoding, you can
|
||||
simply JSON.stringify / JSON.parse the relative position instead.
|
||||
</dd>
|
||||
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
|
||||
<dd></dd>
|
||||
<dd>Decode a binary-encoded relative position to a RelativePositon object.</dd>
|
||||
</dl>
|
||||
|
||||
### Y.UndoManager
|
||||
@@ -856,6 +926,16 @@ undo- or the redo-stack.
|
||||
</dd>
|
||||
<b>
|
||||
<code>
|
||||
on('stack-item-updated', { stackItem: { meta: Map<any,any> }, type: 'undo'
|
||||
| 'redo' })
|
||||
</code>
|
||||
</b>
|
||||
<dd>
|
||||
Register an event that is called when an existing <code>StackItem</code> is updated.
|
||||
This happens when two changes happen within a "captureInterval".
|
||||
</dd>
|
||||
<b>
|
||||
<code>
|
||||
on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo'
|
||||
| 'redo' })
|
||||
</code>
|
||||
@@ -864,6 +944,14 @@ on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo'
|
||||
Register an event that is called when a <code>StackItem</code> is popped from
|
||||
the undo- or the redo-stack.
|
||||
</dd>
|
||||
<b>
|
||||
<code>
|
||||
on('stack-cleared', { undoStackCleared: boolean, redoStackCleared: boolean })
|
||||
</code>
|
||||
</b>
|
||||
<dd>
|
||||
Register an event that is called when the undo- and/or the redo-stack is cleared.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
#### Example: Stop Capturing
|
||||
@@ -956,7 +1044,7 @@ undoManager.on('stack-item-popped', event => {
|
||||
|
||||
*Conflict-free replicated data types* (CRDT) for collaborative editing are an
|
||||
alternative approach to *operational transformation* (OT). A very simple
|
||||
differenciation between the two approaches is that OT attempts to transform
|
||||
differentiation between the two approaches is that OT attempts to transform
|
||||
index positions to ensure convergence (all clients end up with the same
|
||||
content), while CRDTs use mathematical models that usually do not involve index
|
||||
transformations, like linked lists. OT is currently the de-facto standard for
|
||||
@@ -975,16 +1063,17 @@ 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 that suitable for shared text editing suffer from the fact that they only grow
|
||||
in size. There are CRDTs that do not grow in size, but they do not have the
|
||||
characteristics that are benificial for shared text editing (like intention
|
||||
preservation). Yjs implements many improvements to the original algorithm that
|
||||
diminish the trade-off that the document only grows in size. We can't garbage
|
||||
collect deleted structs (tombstones) while ensuring a unique order of the
|
||||
structs. But we can 1. merge preceeding structs into a single struct to reduce
|
||||
the amount of meta information, 2. we can delete content from the struct if it
|
||||
is deleted, and 3. we can garbage collect tombstones if we don't care about the
|
||||
order of the structs anymore (e.g. if the parent was deleted).
|
||||
CRDTs that are suitable for shared text editing suffer from the fact that they
|
||||
only grow in size. There are CRDTs that do not grow in size, but they do not
|
||||
have the characteristics that are benificial for shared text editing (like
|
||||
intention preservation). Yjs implements many improvements to the original
|
||||
algorithm that diminish the trade-off that the document only grows in size. We
|
||||
can't garbage collect deleted structs (tombstones) while ensuring a unique
|
||||
order of the structs. But we can 1. merge preceeding structs into a single
|
||||
struct to reduce the amount of meta information, 2. we can delete content from
|
||||
the struct if it is deleted, and 3. we can garbage collect tombstones if we
|
||||
don't care about the order of the structs anymore (e.g. if the parent was
|
||||
deleted).
|
||||
|
||||
**Examples:**
|
||||
|
||||
|
||||
5619
package-lock.json
generated
5619
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.5.18",
|
||||
"version": "13.5.50",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.cjs",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
@@ -21,16 +22,18 @@
|
||||
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repetition-time 1000 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
|
||||
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs",
|
||||
"postinstall": "node ./sponsor-y.js"
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"import": "./dist/yjs.mjs",
|
||||
"require": "./dist/yjs.cjs"
|
||||
},
|
||||
"./src/index.js": "./src/index.js",
|
||||
"./tests/testHelper.js": "./tests/testHelper.js",
|
||||
"./testHelper": "./dist/testHelper.mjs",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
@@ -38,6 +41,7 @@
|
||||
"dist/src",
|
||||
"src",
|
||||
"tests/testHelper.js",
|
||||
"dist/testHelper.mjs",
|
||||
"sponsor-y.js"
|
||||
],
|
||||
"dictionaries": {
|
||||
@@ -71,19 +75,19 @@
|
||||
},
|
||||
"homepage": "https://docs.yjs.dev",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.42"
|
||||
"lib0": "^0.2.49"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.2.1",
|
||||
"concurrently": "^3.6.1",
|
||||
"typescript": "^4.9.5",
|
||||
"http-server": "^0.12.3",
|
||||
"jsdoc": "^3.6.7",
|
||||
"markdownlint-cli": "^0.23.2",
|
||||
"rollup": "^2.58.0",
|
||||
"rollup": "^2.60.0",
|
||||
"standard": "^16.0.4",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^4.4.4",
|
||||
"y-protocols": "^1.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,23 @@ export default [{
|
||||
sourcemap: true
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/testHelper.js',
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/testHelper.mjs',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
external: id => /^lib0\//.test(id) || id === 'yjs',
|
||||
plugins: [{
|
||||
resolveId (importee) {
|
||||
if (importee === '../src/index.js') {
|
||||
return 'yjs'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
|
||||
12
sponsor-y.js
12
sponsor-y.js
@@ -1,12 +0,0 @@
|
||||
|
||||
try {
|
||||
const log = require('lib0/dist/logging.cjs')
|
||||
|
||||
log.print()
|
||||
log.print(log.BOLD, log.GREEN, log.BOLD, 'Thank you for using Yjs ', log.RED, '❤\n')
|
||||
log.print(
|
||||
log.GREY,
|
||||
'The project has grown considerably in the past year. Too much for me to maintain\nin my spare time. Several companies built their products with Yjs.\nYet, this project receives very little funding. Yjs is far from done. I want to\ncreate more awesome extensions and work on the growing number of open issues.\n', log.BOLD, 'Dear user, the future of this project entirely depends on you.\n')
|
||||
log.print(log.BLUE, log.BOLD, 'Please start funding the project now: https://github.com/sponsors/dmonad \n')
|
||||
log.print(log.GREY, '(This message will be removed when I achieved my funding goal)\n\n')
|
||||
} catch (e) { }
|
||||
29
src/index.js
29
src/index.js
@@ -27,12 +27,13 @@ export {
|
||||
ContentString,
|
||||
ContentType,
|
||||
AbstractType,
|
||||
RelativePosition,
|
||||
getTypeChildren,
|
||||
createRelativePositionFromTypeIndex,
|
||||
createRelativePositionFromJSON,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
compareRelativePositions,
|
||||
AbsolutePosition,
|
||||
RelativePosition,
|
||||
ID,
|
||||
createID,
|
||||
compareIDs,
|
||||
@@ -41,6 +42,7 @@ export {
|
||||
createSnapshot,
|
||||
createDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
cleanupYTextFormatting,
|
||||
snapshot,
|
||||
emptySnapshot,
|
||||
findRootTypeKey,
|
||||
@@ -65,6 +67,8 @@ export {
|
||||
decodeStateVector,
|
||||
logUpdate,
|
||||
logUpdateV2,
|
||||
decodeUpdate,
|
||||
decodeUpdateV2,
|
||||
relativePositionToJSON,
|
||||
isDeleted,
|
||||
isParentOf,
|
||||
@@ -83,19 +87,26 @@ export {
|
||||
encodeRelativePosition,
|
||||
decodeRelativePosition,
|
||||
diffUpdate,
|
||||
diffUpdateV2
|
||||
diffUpdateV2,
|
||||
convertUpdateFormatV1ToV2,
|
||||
convertUpdateFormatV2ToV1,
|
||||
UpdateEncoderV1
|
||||
} from './internals.js'
|
||||
|
||||
const glo = /** @type {any} */ (typeof window !== 'undefined'
|
||||
? window
|
||||
: typeof global !== 'undefined' ? global : {})
|
||||
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof window !== 'undefined'
|
||||
? window
|
||||
// @ts-ignore
|
||||
: typeof global !== 'undefined' ? global : {})
|
||||
|
||||
const importIdentifier = '__ $YJS$ __'
|
||||
|
||||
if (glo[importIdentifier] === true) {
|
||||
/**
|
||||
* Dear reader of this warning message. Please take this seriously.
|
||||
* Dear reader of this message. Please take this seriously.
|
||||
*
|
||||
* If you see this message, please make sure that you only import one version of Yjs. In many cases,
|
||||
* If you see this message, make sure that you only import one version of Yjs. In many cases,
|
||||
* your package manager installs two versions of Yjs that are used by different packages within your project.
|
||||
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
|
||||
* and others use the EcmaScript version of Yjs.
|
||||
@@ -103,7 +114,9 @@ if (glo[importIdentifier] === true) {
|
||||
* This often leads to issues that are hard to debug. We often need to perform constructor checks,
|
||||
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
|
||||
* do the constructor checks anymore - which might break the CRDT algorithm.
|
||||
*
|
||||
* https://github.com/yjs/yjs/issues/438
|
||||
*/
|
||||
console.warn('Yjs was already imported. Importing different versions of Yjs often leads to issues.')
|
||||
console.error('Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438')
|
||||
}
|
||||
glo[importIdentifier] = true
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
|
||||
import * as error from 'lib0/error'
|
||||
|
||||
/**
|
||||
* @param {string} guid
|
||||
* @param {Object<string, any>} opts
|
||||
*/
|
||||
const createDocFromOpts = (guid, opts) => new Doc({ guid, ...opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false })
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -61,7 +67,7 @@ export class ContentDoc {
|
||||
* @return {ContentDoc}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentDoc(this.doc)
|
||||
return new ContentDoc(createDocFromOpts(this.doc.guid, this.opts))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,4 +138,4 @@ export class ContentDoc {
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @return {ContentDoc}
|
||||
*/
|
||||
export const readContentDoc = decoder => new ContentDoc(new Doc({ guid: decoder.readString(), ...decoder.readAny() }))
|
||||
export const readContentDoc = decoder => new ContentDoc(createDocFromOpts(decoder.readString(), decoder.readAny()))
|
||||
|
||||
@@ -100,4 +100,4 @@ export class ContentFormat {
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
export const readContentFormat = decoder => new ContentFormat(decoder.readString(), decoder.readJSON())
|
||||
export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON())
|
||||
|
||||
@@ -39,7 +39,7 @@ export const YXmlTextRefID = 6
|
||||
*/
|
||||
export class ContentType {
|
||||
/**
|
||||
* @param {AbstractType<YEvent>} type
|
||||
* @param {AbstractType<any>} type
|
||||
*/
|
||||
constructor (type) {
|
||||
/**
|
||||
@@ -109,7 +109,7 @@ export class ContentType {
|
||||
if (!item.deleted) {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// Whis will be gc'd later and we want to merge it if possible
|
||||
// This will be gc'd later and we want to merge it if possible
|
||||
// We try to merge all deleted items after each transaction,
|
||||
// but we have no knowledge about that this needs to be merged
|
||||
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
readContentFormat,
|
||||
readContentType,
|
||||
addChangedTypeToTransaction,
|
||||
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
isDeleted,
|
||||
DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error'
|
||||
@@ -125,13 +126,14 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
* @param {Transaction} transaction The Yjs instance.
|
||||
* @param {Item} item
|
||||
* @param {Set<Item>} redoitems
|
||||
* @param {Array<Item>} itemsToDelete
|
||||
* @param {DeleteSet} itemsToDelete
|
||||
* @param {boolean} ignoreRemoteMapChanges
|
||||
*
|
||||
* @return {Item|null}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
|
||||
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges) => {
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ownClientID = doc.clientID
|
||||
@@ -143,42 +145,27 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left
|
||||
let left = null
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let right
|
||||
// make sure that parent is redone
|
||||
if (parentItem !== null && parentItem.deleted === true) {
|
||||
// try to undo parent if it will be undone anyway
|
||||
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges) === null)) {
|
||||
return null
|
||||
}
|
||||
while (parentItem.redone !== null) {
|
||||
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||
}
|
||||
}
|
||||
const parentType = parentItem === null ? /** @type {AbstractType<any>} */ (item.parent) : /** @type {ContentType} */ (parentItem.content).type
|
||||
|
||||
if (item.parentSub === null) {
|
||||
// Is an array item. Insert at the old position
|
||||
left = item.left
|
||||
right = item
|
||||
} else {
|
||||
// Is a map item. Insert as current value
|
||||
left = item
|
||||
while (left.right !== null) {
|
||||
left = left.right
|
||||
if (left.id.client !== ownClientID) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (left.right !== null) {
|
||||
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
|
||||
}
|
||||
right = null
|
||||
}
|
||||
// make sure that parent is redone
|
||||
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
|
||||
// try to undo parent if it will be undone anyway
|
||||
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete) === null) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (parentItem !== null && parentItem.redone !== null) {
|
||||
while (parentItem.redone !== null) {
|
||||
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||
}
|
||||
// find next cloned_redo items
|
||||
while (left !== null) {
|
||||
/**
|
||||
@@ -210,10 +197,27 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
// Iterate right while right is in itemsToDelete
|
||||
// If it is intended to delete right while item is redone, we can expect that item should replace right.
|
||||
while (left !== null && left.right !== null && left.right !== right && itemsToDelete.findIndex(d => d === /** @type {Item} */ (left).right) >= 0) {
|
||||
left = left.right
|
||||
} else {
|
||||
right = null
|
||||
if (item.right && !ignoreRemoteMapChanges) {
|
||||
left = item
|
||||
// Iterate right while right is in itemsToDelete
|
||||
// If it is intended to delete right while item is redone, we can expect that item should replace right.
|
||||
while (left !== null && left.right !== null && isDeleted(itemsToDelete, left.right.id)) {
|
||||
left = left.right
|
||||
}
|
||||
// follow redone
|
||||
// trace redone until parent matches
|
||||
while (left !== null && left.redone !== null) {
|
||||
left = getItemCleanStart(transaction, left.redone)
|
||||
}
|
||||
if (left && left.right !== null) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
left = parentType._map.get(item.parentSub) || null
|
||||
}
|
||||
}
|
||||
const nextClock = getState(store, ownClientID)
|
||||
@@ -222,7 +226,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
|
||||
nextId,
|
||||
left, left && left.lastId,
|
||||
right, right && right.id,
|
||||
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
|
||||
parentType,
|
||||
item.parentSub,
|
||||
item.content.copy()
|
||||
)
|
||||
@@ -281,7 +285,7 @@ export class Item extends AbstractStruct {
|
||||
*/
|
||||
this.parentSub = parentSub
|
||||
/**
|
||||
* If this type's effect is reundone this type refers to the type that undid
|
||||
* If this type's effect is redone this type refers to the type that undid
|
||||
* this operation.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
|
||||
@@ -278,7 +278,7 @@ export class AbstractType {
|
||||
this._eH = createEventHandler()
|
||||
/**
|
||||
* Deep event handlers
|
||||
* @type {EventHandler<Array<YEvent>,Transaction>}
|
||||
* @type {EventHandler<Array<YEvent<any>>,Transaction>}
|
||||
*/
|
||||
this._dEH = createEventHandler()
|
||||
/**
|
||||
@@ -324,9 +324,9 @@ export class AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder
|
||||
*/
|
||||
_write (encoder) { }
|
||||
_write (_encoder) { }
|
||||
|
||||
/**
|
||||
* The first non-deleted item
|
||||
@@ -344,9 +344,9 @@ export class AbstractType {
|
||||
* Must be implemented by each type.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
* @param {Set<null|string>} _parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
_callObserver (transaction, _parentSubs) {
|
||||
if (!transaction.local && this._searchMarker) {
|
||||
this._searchMarker.length = 0
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export class AbstractType {
|
||||
/**
|
||||
* Observe all events that are created by this type and its children.
|
||||
*
|
||||
* @param {function(Array<YEvent>,Transaction):void} f Observer function
|
||||
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
|
||||
*/
|
||||
observeDeep (f) {
|
||||
addEventHandlerListener(this._dEH, f)
|
||||
@@ -382,7 +382,7 @@ export class AbstractType {
|
||||
/**
|
||||
* Unregister an observer function.
|
||||
*
|
||||
* @param {function(Array<YEvent>,Transaction):void} f Observer function
|
||||
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
|
||||
*/
|
||||
unobserveDeep (f) {
|
||||
removeEventHandlerListener(this._dEH, f)
|
||||
@@ -735,6 +735,29 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
return typeListInsertGenericsAfter(transaction, parent, n, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushing content is special as we generally want to push after the last item. So we don't have to update
|
||||
* the serach marker.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeListPushGenerics = (transaction, parent, content) => {
|
||||
// Use the marker with the highest index and iterate to the right.
|
||||
const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start })
|
||||
let n = marker.p
|
||||
if (n) {
|
||||
while (n.right) {
|
||||
n = n.right
|
||||
}
|
||||
}
|
||||
return typeListInsertGenericsAfter(transaction, parent, n, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
typeListForEach,
|
||||
typeListCreateIterator,
|
||||
typeListInsertGenerics,
|
||||
typeListPushGenerics,
|
||||
typeListDelete,
|
||||
typeListMap,
|
||||
YArrayRefID,
|
||||
@@ -22,6 +23,7 @@ import { typeListSlice } from './AbstractType.js'
|
||||
/**
|
||||
* Event that describes the changes on a YArray
|
||||
* @template T
|
||||
* @extends YEvent<YArray<T>>
|
||||
*/
|
||||
export class YArrayEvent extends YEvent {
|
||||
/**
|
||||
@@ -56,11 +58,14 @@ export class YArray extends AbstractType {
|
||||
|
||||
/**
|
||||
* Construct a new YArray containing the specified items.
|
||||
* @template T
|
||||
* @template {Object<string,any>|Array<any>|number|null|string|Uint8Array} T
|
||||
* @param {Array<T>} items
|
||||
* @return {YArray<T>}
|
||||
*/
|
||||
static from (items) {
|
||||
/**
|
||||
* @type {YArray<T>}
|
||||
*/
|
||||
const a = new YArray()
|
||||
a.push(items)
|
||||
return a
|
||||
@@ -82,6 +87,9 @@ export class YArray extends AbstractType {
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {YArray<T>}
|
||||
*/
|
||||
_copy () {
|
||||
return new YArray()
|
||||
}
|
||||
@@ -90,9 +98,12 @@ export class YArray extends AbstractType {
|
||||
* @return {YArray<T>}
|
||||
*/
|
||||
clone () {
|
||||
/**
|
||||
* @type {YArray<T>}
|
||||
*/
|
||||
const arr = new YArray()
|
||||
arr.insert(0, this.toArray().map(el =>
|
||||
el instanceof AbstractType ? el.clone() : el
|
||||
el instanceof AbstractType ? /** @type {typeof el} */ (el.clone()) : el
|
||||
))
|
||||
return arr
|
||||
}
|
||||
@@ -131,7 +142,7 @@ export class YArray extends AbstractType {
|
||||
insert (index, content) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListInsertGenerics(transaction, this, index, content)
|
||||
typeListInsertGenerics(transaction, this, index, /** @type {any} */ (content))
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
|
||||
@@ -142,9 +153,17 @@ export class YArray extends AbstractType {
|
||||
* Appends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to append.
|
||||
*
|
||||
* @todo Use the following implementation in all types.
|
||||
*/
|
||||
push (content) {
|
||||
this.insert(this.length, content)
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListPushGenerics(transaction, this, /** @type {any} */ (content))
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<any>} */ (this._prelimContent).push(...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,9 +268,9 @@ export class YArray extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYArray = decoder => new YArray()
|
||||
export const readYArray = _decoder => new YArray()
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as iterator from 'lib0/iterator'
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @extends YEvent<YMap<T>>
|
||||
* Event that describes the changes on a YMap.
|
||||
*/
|
||||
export class YMapEvent extends YEvent {
|
||||
@@ -80,6 +81,9 @@ export class YMap extends AbstractType {
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {YMap<MapType>}
|
||||
*/
|
||||
_copy () {
|
||||
return new YMap()
|
||||
}
|
||||
@@ -88,9 +92,12 @@ export class YMap extends AbstractType {
|
||||
* @return {YMap<MapType>}
|
||||
*/
|
||||
clone () {
|
||||
/**
|
||||
* @type {YMap<MapType>}
|
||||
*/
|
||||
const map = new YMap()
|
||||
this.forEach((value, key) => {
|
||||
map.set(key, value instanceof AbstractType ? value.clone() : value)
|
||||
map.set(key, value instanceof AbstractType ? /** @type {typeof value} */ (value.clone()) : value)
|
||||
})
|
||||
return map
|
||||
}
|
||||
@@ -166,20 +173,17 @@ export class YMap extends AbstractType {
|
||||
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
|
||||
*/
|
||||
forEach (f) {
|
||||
/**
|
||||
* @type {Object<string,MapType>}
|
||||
*/
|
||||
const map = {}
|
||||
this._map.forEach((item, key) => {
|
||||
if (!item.deleted) {
|
||||
f(item.content.getContent()[item.length - 1], key, this)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {IterableIterator<MapType>}
|
||||
* Returns an Iterator of [key, value] pairs
|
||||
*
|
||||
* @return {IterableIterator<any>}
|
||||
*/
|
||||
[Symbol.iterator] () {
|
||||
return this.entries()
|
||||
@@ -209,7 +213,7 @@ export class YMap extends AbstractType {
|
||||
set (key, value) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeMapSet(transaction, this, key, value)
|
||||
typeMapSet(transaction, this, key, /** @type {any} */ (value))
|
||||
})
|
||||
} else {
|
||||
/** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
|
||||
@@ -243,7 +247,7 @@ export class YMap extends AbstractType {
|
||||
clear () {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
this.forEach(function (value, key, map) {
|
||||
this.forEach(function (_value, key, map) {
|
||||
typeMapDelete(transaction, map, key)
|
||||
})
|
||||
})
|
||||
@@ -261,9 +265,9 @@ export class YMap extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYMap = decoder => new YMap()
|
||||
export const readYMap = _decoder => new YMap()
|
||||
|
||||
@@ -251,7 +251,7 @@ const insertAttributes = (transaction, parent, currPos, attributes) => {
|
||||
* @function
|
||||
**/
|
||||
const insertText = (transaction, parent, currPos, text, attributes) => {
|
||||
currPos.currentAttributes.forEach((val, key) => {
|
||||
currPos.currentAttributes.forEach((_val, key) => {
|
||||
if (attributes[key] === undefined) {
|
||||
attributes[key] = null
|
||||
}
|
||||
@@ -291,7 +291,17 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
|
||||
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
|
||||
// iterate until first non-format or null is found
|
||||
// delete all formats with attributes[format.key] != null
|
||||
while (length > 0 && currPos.right !== null) {
|
||||
// also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
|
||||
// eslint-disable-next-line no-labels
|
||||
iterationLoop: while (
|
||||
currPos.right !== null &&
|
||||
(length > 0 ||
|
||||
(
|
||||
negatedAttributes.size > 0 &&
|
||||
(currPos.right.deleted || currPos.right.content.constructor === ContentFormat)
|
||||
)
|
||||
)
|
||||
) {
|
||||
if (!currPos.right.deleted) {
|
||||
switch (currPos.right.content.constructor) {
|
||||
case ContentFormat: {
|
||||
@@ -301,9 +311,16 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
|
||||
if (equalAttrs(attr, value)) {
|
||||
negatedAttributes.delete(key)
|
||||
} else {
|
||||
if (length === 0) {
|
||||
// no need to further extend negatedAttributes
|
||||
// eslint-disable-next-line no-labels
|
||||
break iterationLoop
|
||||
}
|
||||
negatedAttributes.set(key, value)
|
||||
}
|
||||
currPos.right.delete(transaction)
|
||||
} else {
|
||||
currPos.currentAttributes.set(key, value)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -338,31 +355,55 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} start
|
||||
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
|
||||
* @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
|
||||
* @param {Map<string,any>} startAttributes
|
||||
* @param {Map<string,any>} endAttributes This attribute is modified!
|
||||
* @param {Map<string,any>} currAttributes
|
||||
* @return {number} The amount of formatting Items deleted.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
|
||||
const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let end = start
|
||||
/**
|
||||
* @type {Map<string,ContentFormat>}
|
||||
*/
|
||||
const endFormats = map.create()
|
||||
while (end && (!end.countable || end.deleted)) {
|
||||
if (!end.deleted && end.content.constructor === ContentFormat) {
|
||||
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
|
||||
const cf = /** @type {ContentFormat} */ (end.content)
|
||||
endFormats.set(cf.key, cf)
|
||||
}
|
||||
end = end.right
|
||||
}
|
||||
let cleanups = 0
|
||||
let reachedCurr = false
|
||||
while (start !== end) {
|
||||
if (curr === start) {
|
||||
reachedCurr = true
|
||||
}
|
||||
if (!start.deleted) {
|
||||
const content = start.content
|
||||
switch (content.constructor) {
|
||||
case ContentFormat: {
|
||||
const { key, value } = /** @type {ContentFormat} */ (content)
|
||||
if ((endAttributes.get(key) || null) !== value || (startAttributes.get(key) || null) === value) {
|
||||
const startAttrValue = startAttributes.get(key) || null
|
||||
if (endFormats.get(key) !== content || startAttrValue === value) {
|
||||
// Either this format is overwritten or it is not necessary because the attribute already existed.
|
||||
start.delete(transaction)
|
||||
cleanups++
|
||||
if (!reachedCurr && (currAttributes.get(key) || null) === value && startAttrValue !== value) {
|
||||
if (startAttrValue === null) {
|
||||
currAttributes.delete(key)
|
||||
} else {
|
||||
currAttributes.set(key, startAttrValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reachedCurr && !start.deleted) {
|
||||
updateCurrentAttributes(currAttributes, /** @type {ContentFormat} */ (content))
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -465,7 +506,7 @@ const deleteText = (transaction, currPos, length) => {
|
||||
currPos.forward()
|
||||
}
|
||||
if (start) {
|
||||
cleanupFormattingGap(transaction, start, currPos.right, startAttrs, map.copy(currPos.currentAttributes))
|
||||
cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes)
|
||||
}
|
||||
const parent = /** @type {AbstractType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
|
||||
if (parent._searchMarker) {
|
||||
@@ -502,6 +543,7 @@ const deleteText = (transaction, currPos, length) => {
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends YEvent<YText>
|
||||
* Event that describes the changes on a YText type.
|
||||
*/
|
||||
export class YTextEvent extends YEvent {
|
||||
@@ -684,7 +726,7 @@ export class YTextEvent extends YEvent {
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
} else {
|
||||
} else if (value !== null) {
|
||||
item.delete(transaction)
|
||||
}
|
||||
}
|
||||
@@ -710,7 +752,7 @@ export class YTextEvent extends YEvent {
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
} else {
|
||||
} else if (attr !== null) { // this will be cleaned up automatically by the contextless cleanup function
|
||||
item.delete(transaction)
|
||||
}
|
||||
}
|
||||
@@ -900,7 +942,7 @@ export class YText extends AbstractType {
|
||||
* Apply a {@link Delta} on this shared YText type.
|
||||
*
|
||||
* @param {any} delta The changes to apply on this element.
|
||||
* @param {object} [opts]
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
|
||||
*
|
||||
*
|
||||
@@ -976,27 +1018,19 @@ export class YText extends AbstractType {
|
||||
str = ''
|
||||
}
|
||||
}
|
||||
// snapshots are merged again after the transaction, so we need to keep the
|
||||
// transalive until we are done
|
||||
transact(doc, transaction => {
|
||||
if (snapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, snapshot)
|
||||
}
|
||||
if (prevSnapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, prevSnapshot)
|
||||
}
|
||||
const computeDelta = () => {
|
||||
while (n !== null) {
|
||||
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
|
||||
switch (n.content.constructor) {
|
||||
case ContentString: {
|
||||
const cur = currentAttributes.get('ychange')
|
||||
if (snapshot !== undefined && !isVisible(n, snapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
|
||||
}
|
||||
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
|
||||
}
|
||||
@@ -1037,7 +1071,22 @@ export class YText extends AbstractType {
|
||||
n = n.right
|
||||
}
|
||||
packStr()
|
||||
}, splitSnapshotAffectedStructs)
|
||||
}
|
||||
if (snapshot || prevSnapshot) {
|
||||
// snapshots are merged again after the transaction, so we need to keep the
|
||||
// transaction alive until we are done
|
||||
transact(doc, transaction => {
|
||||
if (snapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, snapshot)
|
||||
}
|
||||
if (prevSnapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, prevSnapshot)
|
||||
}
|
||||
computeDelta()
|
||||
}, 'cleanup')
|
||||
} else {
|
||||
computeDelta()
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -1202,12 +1251,11 @@ export class YText extends AbstractType {
|
||||
*
|
||||
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
|
||||
*
|
||||
* @param {Snapshot} [snapshot]
|
||||
* @return {Object<string, any>} A JSON Object that describes the attributes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getAttributes (snapshot) {
|
||||
getAttributes () {
|
||||
return typeMapGetAll(this)
|
||||
}
|
||||
|
||||
@@ -1220,10 +1268,10 @@ export class YText extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
|
||||
* @return {YText}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYText = decoder => new YText()
|
||||
export const readYText = _decoder => new YText()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
typeMapGetAll,
|
||||
typeListForEach,
|
||||
YXmlElementRefID,
|
||||
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Snapshot, Doc, Item // eslint-disable-line
|
||||
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
@@ -176,12 +176,11 @@ export class YXmlElement extends YXmlFragment {
|
||||
/**
|
||||
* Returns all attribute name/value pairs in a JSON Object.
|
||||
*
|
||||
* @param {Snapshot} [snapshot]
|
||||
* @return {Object<string, any>} A JSON Object that describes the attributes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getAttributes (snapshot) {
|
||||
getAttributes () {
|
||||
return typeMapGetAll(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* @extends YEvent<YXmlElement|YXmlText|YXmlFragment>
|
||||
* An Event that describes changes on a YXml Element or Yxml Fragment
|
||||
*/
|
||||
export class YXmlEvent extends YEvent {
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
transact,
|
||||
typeListGet,
|
||||
typeListSlice,
|
||||
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
/**
|
||||
* Define the elements to which a set of CSS queries apply.
|
||||
@@ -237,7 +238,7 @@ export class YXmlFragment extends AbstractType {
|
||||
querySelectorAll (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
return Array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
|
||||
return array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,6 +405,15 @@ export class YXmlFragment extends AbstractType {
|
||||
return typeListSlice(this, start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provided function on once on overy child element.
|
||||
*
|
||||
* @param {function(YXmlElement|YXmlText,number, typeof self):void} f A function to execute on every element of this YArray.
|
||||
*/
|
||||
forEach (f) {
|
||||
typeListForEach(this, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the properties of this type to binary and write it to an
|
||||
* BinaryEncoder.
|
||||
@@ -418,10 +428,10 @@ export class YXmlFragment extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlFragment = decoder => new YXmlFragment()
|
||||
export const readYXmlFragment = _decoder => new YXmlFragment()
|
||||
|
||||
@@ -219,17 +219,21 @@ export const createDeleteSetFromStructStore = ss => {
|
||||
*/
|
||||
export const writeDeleteSet = (encoder, ds) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
|
||||
ds.clients.forEach((dsitems, client) => {
|
||||
encoder.resetDsCurVal()
|
||||
encoding.writeVarUint(encoder.restEncoder, client)
|
||||
const len = dsitems.length
|
||||
encoding.writeVarUint(encoder.restEncoder, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = dsitems[i]
|
||||
encoder.writeDsClock(item.clock)
|
||||
encoder.writeDsLen(item.len)
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure that the delete set is written in a deterministic order
|
||||
array.from(ds.clients.entries())
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([client, dsitems]) => {
|
||||
encoder.resetDsCurVal()
|
||||
encoding.writeVarUint(encoder.restEncoder, client)
|
||||
const len = dsitems.length
|
||||
encoding.writeVarUint(encoder.restEncoder, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = dsitems[i]
|
||||
encoder.writeDsClock(item.clock)
|
||||
encoder.writeDsLen(item.len)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Observable } from 'lib0/observable'
|
||||
import * as random from 'lib0/random'
|
||||
import * as map from 'lib0/map'
|
||||
import * as array from 'lib0/array'
|
||||
import * as promise from 'lib0/promise'
|
||||
|
||||
export const generateNewClientId = random.uint32
|
||||
|
||||
@@ -28,6 +29,7 @@ export const generateNewClientId = random.uint32
|
||||
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
|
||||
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
|
||||
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
|
||||
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -36,9 +38,9 @@ export const generateNewClientId = random.uint32
|
||||
*/
|
||||
export class Doc extends Observable {
|
||||
/**
|
||||
* @param {DocOpts} [opts] configuration
|
||||
* @param {DocOpts} opts configuration
|
||||
*/
|
||||
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false } = {}) {
|
||||
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) {
|
||||
super()
|
||||
this.gc = gc
|
||||
this.gcFilter = gcFilter
|
||||
@@ -46,7 +48,7 @@ export class Doc extends Observable {
|
||||
this.guid = guid
|
||||
this.collectionid = collectionid
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
* @type {Map<string, AbstractType<YEvent<any>>>}
|
||||
*/
|
||||
this.share = new Map()
|
||||
this.store = new StructStore()
|
||||
@@ -67,9 +69,60 @@ export class Doc extends Observable {
|
||||
* @type {Item?}
|
||||
*/
|
||||
this._item = null
|
||||
this.shouldLoad = autoLoad
|
||||
this.shouldLoad = shouldLoad
|
||||
this.autoLoad = autoLoad
|
||||
this.meta = meta
|
||||
/**
|
||||
* This is set to true when the persistence provider loaded the document from the database or when the `sync` event fires.
|
||||
* Note that not all providers implement this feature. Provider authors are encouraged to fire the `load` event when the doc content is loaded from the database.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.isLoaded = false
|
||||
/**
|
||||
* This is set to true when the connection provider has successfully synced with a backend.
|
||||
* Note that when using peer-to-peer providers this event may not provide very useful.
|
||||
* Also note that not all providers implement this feature. Provider authors are encouraged to fire
|
||||
* the `sync` event when the doc has been synced (with `true` as a parameter) or if connection is
|
||||
* lost (with false as a parameter).
|
||||
*/
|
||||
this.isSynced = false
|
||||
/**
|
||||
* Promise that resolves once the document has been loaded from a presistence provider.
|
||||
*/
|
||||
this.whenLoaded = promise.create(resolve => {
|
||||
this.on('load', () => {
|
||||
this.isLoaded = true
|
||||
resolve(this)
|
||||
})
|
||||
})
|
||||
const provideSyncedPromise = () => promise.create(resolve => {
|
||||
/**
|
||||
* @param {boolean} isSynced
|
||||
*/
|
||||
const eventHandler = (isSynced) => {
|
||||
if (isSynced === undefined || isSynced === true) {
|
||||
this.off('sync', eventHandler)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
this.on('sync', eventHandler)
|
||||
})
|
||||
this.on('sync', isSynced => {
|
||||
if (isSynced === false && this.isSynced) {
|
||||
this.whenSynced = provideSyncedPromise()
|
||||
}
|
||||
this.isSynced = isSynced === undefined || isSynced === true
|
||||
if (!this.isLoaded) {
|
||||
this.emit('load', [])
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Promise that resolves once the document has been synced with a backend.
|
||||
* This promise is recreated when the connection is lost.
|
||||
* Note the documentation about the `isSynced` property.
|
||||
*/
|
||||
this.whenSynced = provideSyncedPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +147,7 @@ export class Doc extends Observable {
|
||||
}
|
||||
|
||||
getSubdocGuids () {
|
||||
return new Set(Array.from(this.subdocs).map(doc => doc.guid))
|
||||
return new Set(array.from(this.subdocs).map(doc => doc.guid))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,13 +156,15 @@ export class Doc extends Observable {
|
||||
* that happened inside of the transaction are sent as one message to the
|
||||
* other peers.
|
||||
*
|
||||
* @param {function(Transaction):void} f The function that should be executed as a transaction
|
||||
* @template T
|
||||
* @param {function(Transaction):T} f The function that should be executed as a transaction
|
||||
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
|
||||
* @return T
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
transact (f, origin = null) {
|
||||
transact(this, f, origin)
|
||||
return transact(this, f, origin)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,16 +303,12 @@ export class Doc extends Observable {
|
||||
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
|
||||
}
|
||||
content.doc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
|
||||
content.doc._item = item
|
||||
transact(/** @type {any} */ (item).parent.doc, transaction => {
|
||||
const doc = content.doc
|
||||
if (!item.deleted) {
|
||||
transaction.subdocsAdded.add(content.doc)
|
||||
transaction.subdocsAdded.add(doc)
|
||||
}
|
||||
transaction.subdocsRemoved.add(this)
|
||||
}, null, true)
|
||||
|
||||
@@ -71,7 +71,7 @@ export class PermanentUserData {
|
||||
* @param {Doc} doc
|
||||
* @param {number} clientid
|
||||
* @param {string} userDescription
|
||||
* @param {Object} [conf]
|
||||
* @param {Object} conf
|
||||
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
|
||||
*/
|
||||
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
|
||||
@@ -84,7 +84,7 @@ export class PermanentUserData {
|
||||
users.set(userDescription, user)
|
||||
}
|
||||
user.get('ids').push([clientid])
|
||||
users.observe(event => {
|
||||
users.observe(_event => {
|
||||
setTimeout(() => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
|
||||
@@ -75,13 +75,13 @@ export class Transaction {
|
||||
* All types that were directly modified (property added or child
|
||||
* inserted/deleted). New types are not included in this Set.
|
||||
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
|
||||
* @type {Map<AbstractType<YEvent<any>>,Set<String|null>>}
|
||||
*/
|
||||
this.changed = new Map()
|
||||
/**
|
||||
* Stores the events for the types that observe also child elements.
|
||||
* It is mainly used by `observeDeep`.
|
||||
* @type {Map<AbstractType<YEvent>,Array<YEvent>>}
|
||||
* @type {Map<AbstractType<YEvent<any>>,Array<YEvent<any>>>}
|
||||
*/
|
||||
this.changedParentTypes = new Map()
|
||||
/**
|
||||
@@ -148,7 +148,7 @@ export const nextID = transaction => {
|
||||
* did not change, it was just added and we should not fire events for `type`.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<YEvent>} type
|
||||
* @param {AbstractType<YEvent<any>>} type
|
||||
* @param {string|null} parentSub
|
||||
*/
|
||||
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
@@ -251,7 +251,6 @@ const cleanupTransactions = (transactionCleanups, i) => {
|
||||
try {
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
@@ -331,8 +330,8 @@ const cleanupTransactions = (transactionCleanups, i) => {
|
||||
}
|
||||
}
|
||||
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.')
|
||||
doc.clientID = generateNewClientId()
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
@@ -377,15 +376,21 @@ const cleanupTransactions = (transactionCleanups, i) => {
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
* @template T
|
||||
* @param {Doc} doc
|
||||
* @param {function(Transaction):void} f
|
||||
* @param {function(Transaction):T} f
|
||||
* @param {any} [origin=true]
|
||||
* @return {T}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const transact = (doc, f, origin = null, local = true) => {
|
||||
const transactionCleanups = doc._transactionCleanups
|
||||
let initialCall = false
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let result = null
|
||||
if (doc._transaction === null) {
|
||||
initialCall = true
|
||||
doc._transaction = new Transaction(doc, origin, local)
|
||||
@@ -396,18 +401,23 @@ export const transact = (doc, f, origin = null, local = true) => {
|
||||
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||
}
|
||||
try {
|
||||
f(doc._transaction)
|
||||
result = f(doc._transaction)
|
||||
} finally {
|
||||
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.
|
||||
// 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)
|
||||
if (initialCall) {
|
||||
const finishCleanup = doc._transaction === transactionCleanups[0]
|
||||
doc._transaction = null
|
||||
if (finishCleanup) {
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../internals.js'
|
||||
|
||||
import * as time from 'lib0/time'
|
||||
import * as array from 'lib0/array'
|
||||
import { Observable } from 'lib0/observable'
|
||||
|
||||
class StackItem {
|
||||
@@ -30,6 +31,18 @@ class StackItem {
|
||||
this.meta = new Map()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} tr
|
||||
* @param {UndoManager} um
|
||||
* @param {StackItem} stackItem
|
||||
*/
|
||||
const clearUndoManagerStackItem = (tr, um, stackItem) => {
|
||||
iterateDeletedStructs(tr, stackItem.deletions, item => {
|
||||
if (item instanceof Item && um.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UndoManager} undoManager
|
||||
@@ -88,7 +101,7 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo, itemsToDelete) !== null || performedChange
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges) !== 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.
|
||||
@@ -119,11 +132,14 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
/**
|
||||
* @typedef {Object} UndoManagerOptions
|
||||
* @property {number} [UndoManagerOptions.captureTimeout=500]
|
||||
* @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false.
|
||||
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
|
||||
* it is necessary to filter whan an Undo/Redo operation can delete. If this
|
||||
* it is necessary to filter what an Undo/Redo operation can delete. If this
|
||||
* filter returns false, the type/item won't be deleted even it is in the
|
||||
* undo/redo scope.
|
||||
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
|
||||
* @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..).
|
||||
* @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -133,19 +149,31 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||
*
|
||||
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
|
||||
* @extends {Observable<'stack-item-added'|'stack-item-popped'|'stack-cleared'|'stack-item-updated'>}
|
||||
*/
|
||||
export class UndoManager extends Observable {
|
||||
/**
|
||||
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
|
||||
* @param {UndoManagerOptions} options
|
||||
*/
|
||||
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
||||
constructor (typeScope, {
|
||||
captureTimeout = 500,
|
||||
captureTransaction = tr => true,
|
||||
deleteFilter = () => true,
|
||||
trackedOrigins = new Set([null]),
|
||||
ignoreRemoteMapChanges = false,
|
||||
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope.doc)
|
||||
} = {}) {
|
||||
super()
|
||||
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
|
||||
/**
|
||||
* @type {Array<AbstractType<any>>}
|
||||
*/
|
||||
this.scope = []
|
||||
this.addToScope(typeScope)
|
||||
this.deleteFilter = deleteFilter
|
||||
trackedOrigins.add(this)
|
||||
this.trackedOrigins = trackedOrigins
|
||||
this.captureTransaction = captureTransaction
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
@@ -161,11 +189,20 @@ export class UndoManager extends Observable {
|
||||
*/
|
||||
this.undoing = false
|
||||
this.redoing = false
|
||||
this.doc = /** @type {Doc} */ (this.scope[0].doc)
|
||||
this.doc = doc
|
||||
this.lastChange = 0
|
||||
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
this.ignoreRemoteMapChanges = ignoreRemoteMapChanges
|
||||
this.captureTimeout = captureTimeout
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
this.afterTransactionHandler = transaction => {
|
||||
// Only track certain transactions
|
||||
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
|
||||
if (
|
||||
!this.captureTransaction(transaction) ||
|
||||
!this.scope.some(type => transaction.changedParentTypes.has(type)) ||
|
||||
(!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const undoing = this.undoing
|
||||
@@ -175,7 +212,7 @@ export class UndoManager extends Observable {
|
||||
this.stopCapturing() // next undo should not be appended to last stack item
|
||||
} else if (!redoing) {
|
||||
// neither undoing nor redoing: delete redoStack
|
||||
this.redoStack = []
|
||||
this.clear(false, true)
|
||||
}
|
||||
const insertions = new DeleteSet()
|
||||
transaction.afterState.forEach((endClock, client) => {
|
||||
@@ -186,7 +223,8 @@ export class UndoManager extends Observable {
|
||||
}
|
||||
})
|
||||
const now = time.getUnixTime()
|
||||
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||
let didAdd = false
|
||||
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||
// append change to last stack op
|
||||
const lastOp = stack[stack.length - 1]
|
||||
lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet])
|
||||
@@ -194,6 +232,7 @@ export class UndoManager extends Observable {
|
||||
} else {
|
||||
// create a new stack op
|
||||
stack.push(new StackItem(transaction.deleteSet, insertions))
|
||||
didAdd = true
|
||||
}
|
||||
if (!undoing && !redoing) {
|
||||
this.lastChange = now
|
||||
@@ -204,27 +243,59 @@ export class UndoManager extends Observable {
|
||||
keepItem(item, true)
|
||||
}
|
||||
})
|
||||
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this])
|
||||
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
|
||||
if (didAdd) {
|
||||
this.emit('stack-item-added', changeEvent)
|
||||
} else {
|
||||
this.emit('stack-item-updated', changeEvent)
|
||||
}
|
||||
}
|
||||
this.doc.on('afterTransaction', this.afterTransactionHandler)
|
||||
this.doc.on('destroy', () => {
|
||||
this.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.doc.transact(transaction => {
|
||||
/**
|
||||
* @param {StackItem} stackItem
|
||||
*/
|
||||
const clearItem = stackItem => {
|
||||
iterateDeletedStructs(transaction, stackItem.deletions, item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, false)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* @param {Array<AbstractType<any>> | AbstractType<any>} ytypes
|
||||
*/
|
||||
addToScope (ytypes) {
|
||||
ytypes = array.isArray(ytypes) ? ytypes : [ytypes]
|
||||
ytypes.forEach(ytype => {
|
||||
if (this.scope.every(yt => yt !== ytype)) {
|
||||
this.scope.push(ytype)
|
||||
}
|
||||
this.undoStack.forEach(clearItem)
|
||||
this.redoStack.forEach(clearItem)
|
||||
})
|
||||
this.undoStack = []
|
||||
this.redoStack = []
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} origin
|
||||
*/
|
||||
addTrackedOrigin (origin) {
|
||||
this.trackedOrigins.add(origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} origin
|
||||
*/
|
||||
removeTrackedOrigin (origin) {
|
||||
this.trackedOrigins.delete(origin)
|
||||
}
|
||||
|
||||
clear (clearUndoStack = true, clearRedoStack = true) {
|
||||
if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
|
||||
this.doc.transact(tr => {
|
||||
if (clearUndoStack) {
|
||||
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||
this.undoStack = []
|
||||
}
|
||||
if (clearRedoStack) {
|
||||
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||
this.redoStack = []
|
||||
}
|
||||
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,4 +353,28 @@ export class UndoManager extends Observable {
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Are undo steps available?
|
||||
*
|
||||
* @return {boolean} `true` if undo is possible
|
||||
*/
|
||||
canUndo () {
|
||||
return this.undoStack.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Are redo steps available?
|
||||
*
|
||||
* @return {boolean} `true` if redo is possible
|
||||
*/
|
||||
canRedo () {
|
||||
return this.redoStack.length > 0
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.trackedOrigins.delete(this)
|
||||
this.doc.off('afterTransaction', this.afterTransactionHandler)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,10 +298,24 @@ export class UpdateEncoderV2 extends DSEncoderV2 {
|
||||
writeKey (key) {
|
||||
const clock = this.keyMap.get(key)
|
||||
if (clock === undefined) {
|
||||
/**
|
||||
* @todo uncomment to introduce this feature finally
|
||||
*
|
||||
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
|
||||
* Furthermore, I forgot to set the keyclock. So everything was working fine.
|
||||
*
|
||||
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
|
||||
*
|
||||
* I don't know yet how to reintroduce this feature..
|
||||
*
|
||||
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
|
||||
*
|
||||
*/
|
||||
// this.keyMap.set(key, this.keyClock)
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
this.stringEncoder.write(key)
|
||||
} else {
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
this.keyClockEncoder.write(clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,18 @@ import * as set from 'lib0/set'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
/**
|
||||
* @template {AbstractType<any>} T
|
||||
* YEvent describes the changes on a YType.
|
||||
*/
|
||||
export class YEvent {
|
||||
/**
|
||||
* @param {AbstractType<any>} target The changed type.
|
||||
* @param {T} target The changed type.
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
constructor (target, transaction) {
|
||||
/**
|
||||
* The type on which this event was created on.
|
||||
* @type {AbstractType<any>}
|
||||
* @type {T}
|
||||
*/
|
||||
this.target = target
|
||||
/**
|
||||
@@ -129,6 +130,11 @@ export class YEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a computed property. Note that this can only be safely computed during the
|
||||
* event call. Computing this property after other changes happened might result in
|
||||
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
|
||||
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
|
||||
*
|
||||
* @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
|
||||
*/
|
||||
get delta () {
|
||||
@@ -148,6 +154,11 @@ export class YEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a computed property. Note that this can only be safely computed during the
|
||||
* event call. Computing this property after other changes happened might result in
|
||||
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
|
||||
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
|
||||
*
|
||||
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
|
||||
*/
|
||||
get changes () {
|
||||
|
||||
@@ -45,6 +45,7 @@ import * as decoding from 'lib0/decoding'
|
||||
import * as binary from 'lib0/binary'
|
||||
import * as map from 'lib0/map'
|
||||
import * as math from 'lib0/math'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
@@ -96,7 +97,7 @@ export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
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]) => {
|
||||
array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
// @ts-ignore
|
||||
writeStructs(encoder, store.clients.get(client), client, clock)
|
||||
})
|
||||
@@ -231,7 +232,7 @@ const integrateStructs = (transaction, store, clientsStructRefs) => {
|
||||
*/
|
||||
const stack = []
|
||||
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
|
||||
let clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
|
||||
let clientsStructRefsIds = array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
|
||||
if (clientsStructRefsIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -601,7 +602,7 @@ export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1
|
||||
*/
|
||||
export const writeStateVector = (encoder, sv) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, sv.size)
|
||||
sv.forEach((clock, client) => {
|
||||
array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,30 @@ export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||
logging.print('DeleteSet: ', ds)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
*
|
||||
*/
|
||||
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||
*
|
||||
*/
|
||||
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||
const structs = []
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
structs.push(curr)
|
||||
}
|
||||
return {
|
||||
structs,
|
||||
ds: readDeleteSet(updateDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class LazyStructWriter {
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
|
||||
@@ -2,12 +2,55 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAfterTransactionRecursion = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxml = ydoc.getXmlFragment('')
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
if (tr.origin === 'test') {
|
||||
yxml.toJSON()
|
||||
}
|
||||
})
|
||||
ydoc.transact(_tr => {
|
||||
for (let i = 0; i < 15000; i++) {
|
||||
yxml.push([new Y.XmlText('a')])
|
||||
}
|
||||
}, 'test')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testOriginInTransaction = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const ytext = doc.getText()
|
||||
/**
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
const origins = []
|
||||
doc.on('afterTransaction', (tr) => {
|
||||
origins.push(tr.origin)
|
||||
if (origins.length <= 1) {
|
||||
ytext.toDelta(Y.snapshot(doc)) // adding a snapshot forces toDelta to create a cleanup transaction
|
||||
doc.transact(() => {
|
||||
ytext.insert(0, 'a')
|
||||
}, 'nested')
|
||||
}
|
||||
})
|
||||
doc.transact(() => {
|
||||
ytext.insert(0, '0')
|
||||
}, 'first')
|
||||
t.compareArrays(origins, ['first', 'cleanup', 'nested'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testClientIdDuplicateChange = tc => {
|
||||
export const testClientIdDuplicateChange = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.clientID = 0
|
||||
const doc2 = new Y.Doc()
|
||||
@@ -19,9 +62,9 @@ export const testClientIdDuplicateChange = tc => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGetTypeEmptyId = tc => {
|
||||
export const testGetTypeEmptyId = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.getText('').insert(0, 'h')
|
||||
doc1.getText().insert(1, 'i')
|
||||
@@ -32,9 +75,9 @@ export const testGetTypeEmptyId = tc => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testToJSON = tc => {
|
||||
export const testToJSON = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
|
||||
|
||||
@@ -59,9 +102,9 @@ export const testToJSON = tc => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdoc = tc => {
|
||||
export const testSubdoc = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
doc.load() // doesn't do anything
|
||||
{
|
||||
@@ -88,7 +131,7 @@ export const testSubdoc = tc => {
|
||||
subdocs.get('a').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
|
||||
subdocs.set('b', new Y.Doc({ guid: 'a' }))
|
||||
subdocs.set('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
|
||||
t.compare(event, [['a'], [], []])
|
||||
subdocs.get('b').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
@@ -124,3 +167,164 @@ export const testSubdoc = tc => {
|
||||
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocLoadEdgeCases = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarray = ydoc.getArray()
|
||||
const subdoc1 = new Y.Doc()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let lastEvent = null
|
||||
ydoc.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
yarray.insert(0, [subdoc1])
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad === false)
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||
subdoc1.destroy()
|
||||
const subdoc2 = yarray.get(0)
|
||||
t.assert(subdoc1 !== subdoc2)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||
// load
|
||||
subdoc2.load()
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||
// apply from remote
|
||||
const ydoc2 = new Y.Doc()
|
||||
ydoc2.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const subdoc3 = ydoc2.getArray().get(0)
|
||||
t.assert(subdoc3.shouldLoad === false)
|
||||
t.assert(subdoc3.autoLoad === false)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
|
||||
// load
|
||||
subdoc3.load()
|
||||
t.assert(subdoc3.shouldLoad)
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocLoadEdgeCasesAutoload = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarray = ydoc.getArray()
|
||||
const subdoc1 = new Y.Doc({ autoLoad: true })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let lastEvent = null
|
||||
ydoc.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
yarray.insert(0, [subdoc1])
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad)
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||
subdoc1.destroy()
|
||||
const subdoc2 = yarray.get(0)
|
||||
t.assert(subdoc1 !== subdoc2)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||
// load
|
||||
subdoc2.load()
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||
// apply from remote
|
||||
const ydoc2 = new Y.Doc()
|
||||
ydoc2.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const subdoc3 = ydoc2.getArray().get(0)
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocsUndo = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const elems = ydoc.getXmlFragment()
|
||||
const undoManager = new Y.UndoManager(elems)
|
||||
const subdoc = new Y.Doc()
|
||||
// @ts-ignore
|
||||
elems.insert(0, [subdoc])
|
||||
undoManager.undo()
|
||||
undoManager.redo()
|
||||
t.assert(elems.length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testLoadDocsEvent = async _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
t.assert(ydoc.isLoaded === false)
|
||||
let loadedEvent = false
|
||||
ydoc.on('load', () => {
|
||||
loadedEvent = true
|
||||
})
|
||||
ydoc.emit('load', [ydoc])
|
||||
await ydoc.whenLoaded
|
||||
t.assert(loadedEvent)
|
||||
t.assert(ydoc.isLoaded)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSyncDocsEvent = async _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
t.assert(ydoc.isLoaded === false)
|
||||
t.assert(ydoc.isSynced === false)
|
||||
let loadedEvent = false
|
||||
ydoc.once('load', () => {
|
||||
loadedEvent = true
|
||||
})
|
||||
let syncedEvent = false
|
||||
ydoc.once('sync', /** @param {any} isSynced */ (isSynced) => {
|
||||
syncedEvent = true
|
||||
t.assert(isSynced)
|
||||
})
|
||||
ydoc.emit('sync', [true, ydoc])
|
||||
await ydoc.whenLoaded
|
||||
const oldWhenSynced = ydoc.whenSynced
|
||||
await ydoc.whenSynced
|
||||
t.assert(loadedEvent)
|
||||
t.assert(syncedEvent)
|
||||
t.assert(ydoc.isLoaded)
|
||||
t.assert(ydoc.isSynced)
|
||||
let loadedEvent2 = false
|
||||
ydoc.on('load', () => {
|
||||
loadedEvent2 = true
|
||||
})
|
||||
let syncedEvent2 = false
|
||||
ydoc.on('sync', (isSynced) => {
|
||||
syncedEvent2 = true
|
||||
t.assert(isSynced === false)
|
||||
})
|
||||
ydoc.emit('sync', [false, ydoc])
|
||||
t.assert(!loadedEvent2)
|
||||
t.assert(syncedEvent2)
|
||||
t.assert(ydoc.isLoaded)
|
||||
t.assert(!ydoc.isSynced)
|
||||
t.assert(ydoc.whenSynced !== oldWhenSynced)
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ export const testPermanentUserData = async tc => {
|
||||
export const testDiffStateVectorOfUpdateIsEmpty = tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
/**
|
||||
* @type {null | Uint8Array}
|
||||
* @type {any}
|
||||
*/
|
||||
let sv = /* any */ (null)
|
||||
let sv = null
|
||||
ydoc.getText().insert(0, 'a')
|
||||
ydoc.on('update', update => {
|
||||
sv = Y.encodeStateVectorFromUpdate(update)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import * as Y from '../src/internals'
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
|
||||
/**
|
||||
* @param {Y.YText} ytext
|
||||
* @param {Y.Text} ytext
|
||||
*/
|
||||
const checkRelativePositions = ytext => {
|
||||
// test if all positions are encoded and restored correctly
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals'
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import { init } from './testHelper'
|
||||
import { init } from './testHelper.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBasicRestoreSnapshot = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, ['hello'])
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.getArray('array').insert(1, ['world'])
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toArray(), ['hello'])
|
||||
t.compare(doc.getArray('array').toArray(), ['hello', 'world'])
|
||||
@@ -21,19 +21,19 @@ export const testBasicRestoreSnapshot = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testEmptyRestoreSnapshot = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const snap = snapshot(doc)
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const snap = Y.snapshot(doc)
|
||||
snap.sv.set(9999, 0)
|
||||
doc.getArray().insert(0, ['world'])
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray().toArray(), [])
|
||||
t.compare(doc.getArray().toArray(), ['world'])
|
||||
|
||||
// now this snapshot reflects the latest state. It shoult still work.
|
||||
const snap2 = snapshot(doc)
|
||||
const docRestored2 = createDocFromSnapshot(doc, snap2)
|
||||
const snap2 = Y.snapshot(doc)
|
||||
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
|
||||
t.compare(docRestored2.getArray().toArray(), ['world'])
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ export const testEmptyRestoreSnapshot = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRestoreSnapshotWithSubType = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, [new YMap()])
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, [new Y.Map()])
|
||||
const subMap = doc.getArray('array').get(0)
|
||||
subMap.set('key1', 'value1')
|
||||
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
subMap.set('key2', 'value2')
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toJSON(), [{
|
||||
key1: 'value1'
|
||||
@@ -64,13 +64,13 @@ export const testRestoreSnapshotWithSubType = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRestoreDeletedItem1 = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, ['item1', 'item2'])
|
||||
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.getArray('array').delete(0)
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2'])
|
||||
t.compare(doc.getArray('array').toArray(), ['item2'])
|
||||
@@ -80,15 +80,15 @@ export const testRestoreDeletedItem1 = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRestoreLeftItem = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, ['item1'])
|
||||
doc.getMap('map').set('test', 1)
|
||||
doc.getArray('array').insert(0, ['item0'])
|
||||
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.getArray('array').delete(1)
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1'])
|
||||
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||
@@ -98,13 +98,13 @@ export const testRestoreLeftItem = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDeletedItemsBase = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, ['item1'])
|
||||
doc.getArray('array').delete(0)
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.getArray('array').insert(0, ['item0'])
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toArray(), [])
|
||||
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||
@@ -114,13 +114,13 @@ export const testDeletedItemsBase = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDeletedItems2 = tc => {
|
||||
const doc = new Doc({ gc: false })
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
|
||||
doc.getArray('array').delete(1)
|
||||
const snap = snapshot(doc)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.getArray('array').insert(0, ['item0'])
|
||||
|
||||
const docRestored = createDocFromSnapshot(doc, snap)
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3'])
|
||||
t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3'])
|
||||
@@ -140,11 +140,11 @@ export const testDependentChanges = tc => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @type Doc
|
||||
* @type {Y.Doc}
|
||||
*/
|
||||
const doc0 = array0.doc
|
||||
/**
|
||||
* @type Doc
|
||||
* @type {Y.Doc}
|
||||
*/
|
||||
const doc1 = array1.doc
|
||||
|
||||
@@ -156,16 +156,16 @@ export const testDependentChanges = tc => {
|
||||
array1.insert(1, ['user2item1'])
|
||||
testConnector.syncAll()
|
||||
|
||||
const snap = snapshot(array0.doc)
|
||||
const snap = Y.snapshot(array0.doc)
|
||||
|
||||
array0.insert(2, ['user1item2'])
|
||||
testConnector.syncAll()
|
||||
array1.insert(3, ['user2item2'])
|
||||
testConnector.syncAll()
|
||||
|
||||
const docRestored0 = createDocFromSnapshot(array0.doc, snap)
|
||||
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
|
||||
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||
|
||||
const docRestored1 = createDocFromSnapshot(array1.doc, snap)
|
||||
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
|
||||
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import * as t from 'lib0/testing'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as syncProtocol from 'y-protocols/sync.js'
|
||||
import * as syncProtocol from 'y-protocols/sync'
|
||||
import * as object from 'lib0/object'
|
||||
import * as Y from '../src/internals.js'
|
||||
export * from '../src/internals.js'
|
||||
import * as map from 'lib0/map'
|
||||
import * as Y from '../src/index.js'
|
||||
export * from '../src/index.js'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
@@ -89,8 +90,8 @@ export class TestYInstance extends Y.Doc {
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||
this.updates.push(update)
|
||||
}
|
||||
this.updates.push(update)
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
@@ -133,12 +134,7 @@ export class TestYInstance extends Y.Doc {
|
||||
* @param {TestYInstance} remoteClient
|
||||
*/
|
||||
_receive (message, remoteClient) {
|
||||
let messages = this.receiving.get(remoteClient)
|
||||
if (messages === undefined) {
|
||||
messages = []
|
||||
this.receiving.set(remoteClient, messages)
|
||||
}
|
||||
messages.push(message)
|
||||
map.setIfUndefined(this.receiving, remoteClient, () => []).push(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,17 +198,6 @@ export class TestConnector {
|
||||
// send reply message
|
||||
sender._receive(encoding.toUint8Array(encoder), receiver)
|
||||
}
|
||||
{
|
||||
// If update message, add the received message to the list of received messages
|
||||
const decoder = decoding.createDecoder(m)
|
||||
const messageType = decoding.readVarUint(decoder)
|
||||
switch (messageType) {
|
||||
case syncProtocol.messageYjsUpdate:
|
||||
case syncProtocol.messageYjsSyncStep2:
|
||||
receiver.updates.push(decoding.readVarUint8Array(decoder))
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -279,7 +264,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.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}}
|
||||
* @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}}
|
||||
*/
|
||||
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
/**
|
||||
@@ -304,7 +289,7 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
result.users.push(y)
|
||||
result['array' + i] = y.getArray('array')
|
||||
result['map' + i] = y.getMap('map')
|
||||
result['xml' + i] = y.get('xml', Y.YXmlElement)
|
||||
result['xml' + i] = y.get('xml', Y.XmlElement)
|
||||
result['text' + i] = y.getText('text')
|
||||
}
|
||||
testConnector.syncAll()
|
||||
@@ -335,7 +320,7 @@ export const compare = users => {
|
||||
users.push(.../** @type {any} */(mergedDocs))
|
||||
const userArrayValues = users.map(u => u.getArray('array').toJSON())
|
||||
const userMapValues = users.map(u => u.getMap('map').toJSON())
|
||||
const userXmlValues = users.map(u => u.get('xml', Y.YXmlElement).toString())
|
||||
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
|
||||
const userTextValues = users.map(u => u.getText('text').toDelta())
|
||||
for (const u of users) {
|
||||
t.assert(u.store.pendingDs === null)
|
||||
@@ -370,7 +355,7 @@ export const compare = users => {
|
||||
}
|
||||
return true
|
||||
})
|
||||
t.compare(Y.getStateVector(users[i].store), Y.getStateVector(users[i + 1].store))
|
||||
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
|
||||
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
|
||||
compareStructStores(users[i].store, users[i + 1].store)
|
||||
}
|
||||
@@ -385,8 +370,8 @@ export const compare = users => {
|
||||
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
|
||||
|
||||
/**
|
||||
* @param {Y.StructStore} ss1
|
||||
* @param {Y.StructStore} ss2
|
||||
* @param {import('../src/internals').StructStore} ss1
|
||||
* @param {import('../src/internals').StructStore} ss2
|
||||
*/
|
||||
export const compareStructStores = (ss1, ss2) => {
|
||||
t.assert(ss1.clients.size === ss2.clients.size)
|
||||
@@ -428,13 +413,13 @@ export const compareStructStores = (ss1, ss2) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.DeleteSet} ds1
|
||||
* @param {Y.DeleteSet} ds2
|
||||
* @param {import('../src/internals').DeleteSet} ds1
|
||||
* @param {import('../src/internals').DeleteSet} ds2
|
||||
*/
|
||||
export const compareDS = (ds1, ds2) => {
|
||||
t.assert(ds1.clients.size === ds2.clients.size)
|
||||
ds1.clients.forEach((deleteItems1, client) => {
|
||||
const deleteItems2 = /** @type {Array<Y.DeleteItem>} */ (ds2.clients.get(client))
|
||||
const deleteItems2 = /** @type {Array<import('../src/internals').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]
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||
|
||||
import {
|
||||
UndoManager
|
||||
} from '../src/internals.js'
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInfiniteCaptureTimeout = tc => {
|
||||
const { array0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(array0, { captureTimeout: Number.MAX_VALUE })
|
||||
array0.push([1, 2, 3])
|
||||
undoManager.stopCapturing()
|
||||
array0.push([4, 5, 6])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [1, 2, 3])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoText = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
|
||||
// items that are added & deleted in the same transaction won't be undo
|
||||
text0.insert(0, 'test')
|
||||
@@ -53,6 +62,20 @@ 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 testEmptyTypeScope = tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const um = new Y.UndoManager([], { doc: ydoc })
|
||||
const yarray = ydoc.getArray()
|
||||
um.addToScope(yarray)
|
||||
yarray.insert(0, [1])
|
||||
um.undo()
|
||||
t.assert(yarray.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case to fix #241
|
||||
* @param {t.TestCase} tc
|
||||
@@ -81,7 +104,7 @@ export const testDoubleUndo = tc => {
|
||||
export const testUndoMap = tc => {
|
||||
const { testConnector, map0, map1 } = init(tc, { users: 2 })
|
||||
map0.set('a', 0)
|
||||
const undoManager = new UndoManager(map0)
|
||||
const undoManager = new Y.UndoManager(map0)
|
||||
map0.set('a', 1)
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('a') === 0)
|
||||
@@ -120,7 +143,7 @@ export const testUndoMap = tc => {
|
||||
*/
|
||||
export const testUndoArray = tc => {
|
||||
const { testConnector, array0, array1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(array0)
|
||||
const undoManager = new Y.UndoManager(array0)
|
||||
array0.insert(0, [1, 2, 3])
|
||||
array1.insert(0, [4, 5, 6])
|
||||
testConnector.syncAll()
|
||||
@@ -171,7 +194,7 @@ export const testUndoArray = tc => {
|
||||
*/
|
||||
export const testUndoXml = tc => {
|
||||
const { xml0 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(xml0)
|
||||
const undoManager = new Y.UndoManager(xml0)
|
||||
const child = new Y.XmlElement('p')
|
||||
xml0.insert(0, [child])
|
||||
const textchild = new Y.XmlText('content')
|
||||
@@ -196,7 +219,7 @@ export const testUndoXml = tc => {
|
||||
*/
|
||||
export const testUndoEvents = tc => {
|
||||
const { text0 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
let counter = 0
|
||||
let receivedMetadata = -1
|
||||
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||
@@ -222,7 +245,7 @@ export const testUndoEvents = tc => {
|
||||
export const testTrackClass = tc => {
|
||||
const { users, text0 } = init(tc, { users: 3 })
|
||||
// only track origins that are numbers
|
||||
const undoManager = new UndoManager(text0, { trackedOrigins: new Set([Number]) })
|
||||
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
|
||||
users[0].transact(() => {
|
||||
text0.insert(0, 'abc')
|
||||
}, 42)
|
||||
@@ -240,8 +263,8 @@ export const testTypeScope = tc => {
|
||||
const text0 = new Y.Text()
|
||||
const text1 = new Y.Text()
|
||||
array0.insert(0, [text0, text1])
|
||||
const undoManager = new UndoManager(text0)
|
||||
const undoManagerBoth = new UndoManager([text0, text1])
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
const undoManagerBoth = new Y.UndoManager([text0, text1])
|
||||
text1.insert(0, 'abc')
|
||||
t.assert(undoManager.undoStack.length === 0)
|
||||
t.assert(undoManagerBoth.undoStack.length === 1)
|
||||
@@ -252,15 +275,35 @@ export const testTypeScope = tc => {
|
||||
t.assert(text1.toString() === '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoInEmbed = tc => {
|
||||
const { text0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
const nestedText = new Y.Text('initial text')
|
||||
undoManager.stopCapturing()
|
||||
text0.insertEmbed(0, nestedText, { bold: true })
|
||||
t.assert(nestedText.toString() === 'initial text')
|
||||
undoManager.stopCapturing()
|
||||
nestedText.delete(0, nestedText.length)
|
||||
nestedText.insert(0, 'other text')
|
||||
t.assert(nestedText.toString() === 'other text')
|
||||
undoManager.undo()
|
||||
t.assert(nestedText.toString() === 'initial text')
|
||||
undoManager.undo()
|
||||
t.assert(text0.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoDeleteFilter = tc => {
|
||||
/**
|
||||
* @type {Array<Y.Map<any>>}
|
||||
* @type {Y.Array<any>}
|
||||
*/
|
||||
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
|
||||
const undoManager = new UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
|
||||
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
|
||||
const map0 = new Y.Map()
|
||||
map0.set('hi', 1)
|
||||
const map1 = new Y.Map()
|
||||
@@ -356,3 +399,248 @@ export const testUndoNestedUndoIssue = tc => {
|
||||
undoManager.redo()
|
||||
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/355
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testConsecutiveRedoBug = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const yRoot = doc.getMap()
|
||||
const undoMgr = new Y.UndoManager(yRoot)
|
||||
|
||||
let yPoint = new Y.Map()
|
||||
yPoint.set('x', 0)
|
||||
yPoint.set('y', 0)
|
||||
yRoot.set('a', yPoint)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.set('x', 100)
|
||||
yPoint.set('y', 100)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.set('x', 200)
|
||||
yPoint.set('y', 200)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.set('x', 300)
|
||||
yPoint.set('y', 300)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
|
||||
|
||||
undoMgr.undo() // x=200, y=200
|
||||
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
|
||||
undoMgr.undo() // x=100, y=100
|
||||
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
|
||||
undoMgr.undo() // x=0, y=0
|
||||
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
|
||||
undoMgr.undo() // nil
|
||||
t.compare(yRoot.get('a'), undefined)
|
||||
|
||||
undoMgr.redo() // x=0, y=0
|
||||
yPoint = yRoot.get('a')
|
||||
|
||||
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
|
||||
undoMgr.redo() // x=100, y=100
|
||||
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
|
||||
undoMgr.redo() // x=200, y=200
|
||||
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
|
||||
undoMgr.redo() // expected x=300, y=300, actually nil
|
||||
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/304
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoXmlBug = tc => {
|
||||
const origin = 'origin'
|
||||
const doc = new Y.Doc()
|
||||
const fragment = doc.getXmlFragment('t')
|
||||
const undoManager = new Y.UndoManager(fragment, {
|
||||
captureTimeout: 0,
|
||||
trackedOrigins: new Set([origin])
|
||||
})
|
||||
|
||||
// create element
|
||||
doc.transact(() => {
|
||||
const e = new Y.XmlElement('test-node')
|
||||
e.setAttribute('a', '100')
|
||||
e.setAttribute('b', '0')
|
||||
fragment.insert(fragment.length, [e])
|
||||
}, origin)
|
||||
|
||||
// change one attribute
|
||||
doc.transact(() => {
|
||||
const e = fragment.get(0)
|
||||
e.setAttribute('a', '200')
|
||||
}, origin)
|
||||
|
||||
// change both attributes
|
||||
doc.transact(() => {
|
||||
const e = fragment.get(0)
|
||||
e.setAttribute('a', '180')
|
||||
e.setAttribute('b', '50')
|
||||
}, origin)
|
||||
|
||||
undoManager.undo()
|
||||
undoManager.undo()
|
||||
undoManager.undo()
|
||||
|
||||
undoManager.redo()
|
||||
undoManager.redo()
|
||||
undoManager.redo()
|
||||
t.compare(fragment.toString(), '<test-node a="180" b="50"></test-node>')
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/343
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoBlockBug = tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const design = doc.getMap()
|
||||
|
||||
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
|
||||
|
||||
const text = new Y.Map()
|
||||
|
||||
const blocks1 = new Y.Array()
|
||||
const blocks1block = new Y.Map()
|
||||
doc.transact(() => {
|
||||
blocks1block.set('text', '1')
|
||||
blocks1.push([blocks1block])
|
||||
|
||||
text.set('blocks', blocks1block)
|
||||
design.set('text', text)
|
||||
})
|
||||
|
||||
const blocks2 = new Y.Array()
|
||||
const blocks2block = new Y.Map()
|
||||
doc.transact(() => {
|
||||
blocks2block.set('text', '2')
|
||||
blocks2.push([blocks2block])
|
||||
text.set('blocks', blocks2block)
|
||||
})
|
||||
|
||||
const blocks3 = new Y.Array()
|
||||
const blocks3block = new Y.Map()
|
||||
doc.transact(() => {
|
||||
blocks3block.set('text', '3')
|
||||
blocks3.push([blocks3block])
|
||||
text.set('blocks', blocks3block)
|
||||
})
|
||||
|
||||
const blocks4 = new Y.Array()
|
||||
const blocks4block = new Y.Map()
|
||||
doc.transact(() => {
|
||||
blocks4block.set('text', '4')
|
||||
blocks4.push([blocks4block])
|
||||
text.set('blocks', blocks4block)
|
||||
})
|
||||
|
||||
// {"text":{"blocks":{"text":"4"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"3"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"text":"2"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"text":"1"}}}
|
||||
undoManager.undo() // {}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"1"}}}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"2"}}}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"3"}}}
|
||||
undoManager.redo() // {"text":{}}
|
||||
t.compare(design.toJSON(), { text: { blocks: { text: '4' } } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo text formatting delete should not corrupt peer state.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/392
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoDeleteTextFormat = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const text = doc.getText()
|
||||
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
|
||||
const doc2 = new Y.Doc()
|
||||
const text2 = doc2.getText()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
const undoManager = new Y.UndoManager(text)
|
||||
|
||||
text.format(13, 7, { bold: true })
|
||||
undoManager.stopCapturing()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
text.format(16, 4, { bold: null })
|
||||
undoManager.stopCapturing()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
undoManager.undo()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
const expect = [
|
||||
{ insert: 'Attack ships ' },
|
||||
{
|
||||
insert: 'on fire',
|
||||
attributes: { bold: true }
|
||||
},
|
||||
{ insert: ' off the shoulder of Orion.' }
|
||||
]
|
||||
t.compare(text.toDelta(), expect)
|
||||
t.compare(text2.toDelta(), expect)
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo text formatting delete should not corrupt peer state.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/392
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBehaviorOfIgnoreremotemapchangesProperty = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
|
||||
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
|
||||
const map1 = doc.getMap()
|
||||
const map2 = doc2.getMap()
|
||||
const um1 = new Y.UndoManager(map1, { ignoreRemoteMapChanges: true })
|
||||
map1.set('x', 1)
|
||||
map2.set('x', 2)
|
||||
map1.set('x', 3)
|
||||
map2.set('x', 4)
|
||||
um1.undo()
|
||||
t.assert(map1.get('x') === 2)
|
||||
t.assert(map2.get('x') === 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Special deletion case.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/447
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSpecialDeletionCase = tc => {
|
||||
const origin = 'undoable'
|
||||
const doc = new Y.Doc()
|
||||
const fragment = doc.getXmlFragment()
|
||||
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
|
||||
doc.transact(() => {
|
||||
const e = new Y.XmlElement('test')
|
||||
e.setAttribute('a', '1')
|
||||
e.setAttribute('b', '2')
|
||||
fragment.insert(0, [e])
|
||||
})
|
||||
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
|
||||
doc.transact(() => {
|
||||
// change attribute "b" and delete test-node
|
||||
const e = fragment.get(0)
|
||||
e.setAttribute('b', '3')
|
||||
fragment.delete(0)
|
||||
}, origin)
|
||||
t.compareStrings(fragment.toString(), '')
|
||||
undoManager.undo()
|
||||
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
|
||||
}
|
||||
|
||||
@@ -112,6 +112,24 @@ export const testMergeUpdates = tc => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testKeyEncoding = tc => {
|
||||
const { users, text0, text1 } = init(tc, { users: 2 })
|
||||
|
||||
text0.insert(0, 'a', { italic: true })
|
||||
text0.insert(0, 'b')
|
||||
text0.insert(0, 'c', { italic: true })
|
||||
|
||||
const update = Y.encodeStateAsUpdateV2(users[0])
|
||||
Y.applyUpdateV2(users[1], update)
|
||||
|
||||
t.compare(text1.toDelta(), [{ insert: 'c', attributes: { italic: true } }, { insert: 'b' }, { insert: 'a', attributes: { italic: true } }])
|
||||
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.Doc} ydoc
|
||||
* @param {Array<Uint8Array>} updates - expecting at least 4 updates
|
||||
|
||||
@@ -32,6 +32,17 @@ export const testSlice = tc => {
|
||||
t.compareArrays(arr.slice(0, 2), [0, 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testArrayFrom = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const db1 = doc1.getMap('root')
|
||||
const nestedArray1 = Y.Array.from([0, 1, 2])
|
||||
db1.set('array', nestedArray1)
|
||||
t.compare(nestedArray1.toArray(), [0, 1, 2])
|
||||
}
|
||||
|
||||
/**
|
||||
* Debugging yjs#297 - a critical bug connected to the search-marker approach
|
||||
*
|
||||
|
||||
@@ -455,9 +455,9 @@ export const testChangeEvent = tc => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const map = doc.getMap('map')
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user