Compare commits

...

69 Commits

Author SHA1 Message Date
Kevin Jahns
a5a48d07f6 13.1.1 2020-06-04 18:15:58 +02:00
Kevin Jahns
7b16d5c92d implement pivoting in struct search 2020-06-04 18:14:41 +02:00
Kevin Jahns
ee147c14f1 Merge branch 'master' of github.com:yjs/yjs 2020-06-04 17:07:27 +02:00
Kevin Jahns
e86d5ba25b fix ref offset issue 2020-06-04 17:07:17 +02:00
Kevin Jahns
149ca6f636 Merge pull request #205 from Kisama/ytext-newline-option
Add sanitize option
2020-06-03 19:22:29 +02:00
Cole
e4223760b0 - rollback shorter url to original and ignore max length check for specific line
- add opts sanitize for applyDelata in YText
- apply applyDelata document about YText
2020-06-03 11:18:09 +09:00
Cole
9d3dd4e082 Add setter form permit empty paragraph at the end of the content when applyDelta. 2020-06-03 11:15:03 +09:00
Cole
5a4ff33bf4 Merge branch 'master' of github.com:yjs/yjs 2020-06-03 11:12:38 +09:00
Kevin Jahns
a059fa12e9 13.1.0 2020-06-02 23:52:56 +02:00
Kevin Jahns
0628d8f1c9 fix linting 2020-06-02 23:44:13 +02:00
Kevin Jahns
19e2d51190 Merge branch 'master' of github.com:yjs/yjs 2020-06-02 23:20:54 +02:00
Kevin Jahns
60fab42b3f improve memory allocation ⇒ less "minor gc" cleanups 2020-06-02 23:20:45 +02:00
Cole
469404c6e1 move quill relate newline remove logic to y-quill 2020-06-01 19:17:54 +09:00
Kevin Jahns
c9756e5b57 add npm funding url 2020-05-31 23:24:35 +02:00
Kevin Jahns
601d24e930 Add more backers ❤️ 2020-05-30 21:20:59 +02:00
Kevin Jahns
b2c16674f2 Add sponsors to readme ❤️ 2020-05-29 15:19:43 +02:00
Kevin Jahns
13da804b5e use organization funding and issue template file 2020-05-18 23:46:32 +02:00
Kevin Jahns
c5ca7b6f8c Update issue templates 2020-05-18 23:31:10 +02:00
Kevin Jahns
f4b68c0dd4 Merge pull request #200 from Mansehej/yarray-unshift
Implement unshift function in Y-Array
2020-05-18 22:14:13 +02:00
Mansehej
4407f70052 Update ReadMe for y-array unshift 2020-05-19 01:01:23 +05:30
Mansehej
8bb52a485a Implement unshift to y-arrays 2020-05-19 01:01:23 +05:30
Kevin Jahns
9fc18d5ce0 fix lint issues 2020-05-18 18:43:16 +02:00
Kevin Jahns
ada4f400b5 Merge branch 'mohe2015-patch-1' 2020-05-18 18:04:18 +02:00
Kevin Jahns
06048b87ee rework provider combination demo 2020-05-18 18:04:04 +02:00
Kevin Jahns
05dde1db01 Merge branch 'patch-1' of git://github.com/mohe2015/yjs into mohe2015-patch-1 2020-05-18 17:41:20 +02:00
Kevin Jahns
b5b32c5b3c add relm and nimbus as users of Yjs 2020-05-18 17:09:44 +02:00
Kevin Jahns
3f0e2078de Update README.md 2020-05-14 17:01:49 +02:00
Kevin Jahns
21470bb409 Update README.md 2020-05-14 16:59:48 +02:00
Kevin Jahns
772bb87d5c 13.0.8 2020-05-13 19:29:51 +02:00
Kevin Jahns
dab172fa1d Rework UndoManager to support changes from other / multiple users 2020-05-13 19:28:30 +02:00
Kevin Jahns
a70c5112cd fix wrong type declaration in documentation. fixes #195 2020-05-11 11:10:38 +02:00
Kevin Jahns
7cb423c046 13.0.7 2020-05-11 01:46:51 +02:00
Kevin Jahns
4547b35641 cleanup formatting attributes 2020-05-11 01:45:27 +02:00
Kevin Jahns
4c87f9a021 13.0.6 2020-05-08 14:50:53 +02:00
Kevin Jahns
4b08c67e06 bump lib0 to fix critical encoding issue in safari 2020-05-08 14:49:50 +02:00
Kevin Jahns
9f5bc9ddfe change client id when duplicate content is detected 2020-05-03 16:10:58 +02:00
Moritz Hedtke
8221db795a Update README.md 2020-04-27 22:39:09 +02:00
Moritz Hedtke
68b4418956 Update README.md 2020-04-27 22:35:37 +02:00
Moritz Hedtke
fa09ebfd82 Add example of combining providers to README.md 2020-04-27 22:31:26 +02:00
Kevin Jahns
b399ffa765 add gc information to API docs 2020-04-26 13:24:18 +02:00
Kevin Jahns
180f4667c1 Readme correction: UndoManager accepts options 2020-04-17 02:02:09 +02:00
Kevin Jahns
9455373611 Merge branch 'master' of github.com:yjs/yjs 2020-04-15 20:50:29 +02:00
Kevin Jahns
aa804d89c0 update now.sh links 2020-04-15 19:52:34 +02:00
Kevin Jahns
3ef51a5d1a run test-exhaustive 2020-04-03 12:11:25 +02:00
Kevin Jahns
e61089c659 npm ci before workflow start 2020-04-03 12:09:13 +02:00
Kevin Jahns
97625cf29b fix workflow 2020-04-03 12:05:43 +02:00
Kevin Jahns
a5dc6c27aa Setup github workflow 2020-04-03 12:02:37 +02:00
Kevin Jahns
26a51bafc9 13.0.5 2020-04-02 01:05:04 +02:00
Kevin Jahns
f40e09d156 type fixes for breaking typescript@3.8.* release 2020-04-02 01:03:30 +02:00
Kevin Jahns
81650bc8f6 Merge branch 'gived-ISNIT0/187' 2020-04-01 23:44:40 +02:00
Kevin Jahns
c87caafeb6 lint & refactor PR #187 2020-04-01 23:39:27 +02:00
Kevin Jahns
195b26d90f Merge branch 'ISNIT0/187' of https://github.com/gived/yjs into gived-ISNIT0/187 2020-04-01 14:05:18 +02:00
Kevin Jahns
7e0189ca84 Merge branch 'master' of github.com:yjs/yjs 2020-04-01 14:04:45 +02:00
Kevin Jahns
192706f2a8 update readme 2020-04-01 14:04:41 +02:00
Joe Reeve
a4ce8ae07d 🐛 fix for #187 2020-03-31 16:06:28 +01:00
Kevin Jahns
e04a980af1 Merge pull request #184 from yjs/readme-cleanup
remove deadlinks
2020-03-21 21:50:43 +01:00
Nik Graf
47d40eb6b0 remove deadlinks 2020-03-21 15:51:39 +01:00
Kevin Jahns
fc4a39cc7d Merge pull request #182 from LucasGenoud/patch-1
Update lib0 to latest version
2020-02-27 18:13:22 +01:00
LucasGenoud
44e1fd9f14 Update lib0 to latest version 2020-02-27 10:51:21 +01:00
Kevin Jahns
02cc5a215f bump lib0 2020-02-19 09:49:54 -06:00
Kevin Jahns
d1e8d50c43 13.0.4 2020-02-12 10:53:56 +01:00
Kevin Jahns
18bb2d0719 fix imports in esm bundle 2020-02-12 10:52:51 +01:00
Kevin Jahns
45df311dd7 13.0.3 2020-02-12 10:38:28 +01:00
Kevin Jahns
62888b4004 bundle yjs as a module to prevent declaration issues from circular dependencies 2020-02-12 10:37:22 +01:00
Kevin Jahns
76c389dba0 13.0.2 2020-02-03 12:23:39 +01:00
Kevin Jahns
78fa98c000 add type definition for YText.length 2020-02-03 12:22:35 +01:00
Kevin Jahns
e9f9e08450 13.0.1 2020-01-27 03:43:45 +01:00
Kevin Jahns
e3c59b0aa7 more options to gc data (undomanager.clear and tryGc) 2020-01-27 03:42:32 +01:00
Kevin Jahns
705dce7838 add y-indexeddb section 2020-01-23 22:49:04 +01:00
37 changed files with 1494 additions and 1118 deletions

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: dmonad
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

31
.github/workflows/nodejs.yml vendored Normal file
View File

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

162
README.md
View File

@@ -15,13 +15,52 @@ suited for even large documents.
* Demos: [https://github.com/yjs/yjs-demos](https://github.com/yjs/yjs-demos) * Demos: [https://github.com/yjs/yjs-demos](https://github.com/yjs/yjs-demos)
* Discuss: [https://discuss.yjs.dev](https://discuss.yjs.dev) * Discuss: [https://discuss.yjs.dev](https://discuss.yjs.dev)
* Benchmarks: * Benchmark Yjs vs. Automerge:
[https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks) [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 [**"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/) * Podcast [**"Google Docs-style editing in Gutenberg with the YJS framework":**](https://publishpress.com/blog/yjs/)
:construction_worker_woman: If you are looking for professional support to build :construction_worker_woman: If you are looking for professional (paid) support to
collaborative or distributed applications ping us at <yjs@tag1consulting.com>. build collaborative or distributed applications ping us at
<yjs@tag1consulting.com>. Otherwise you can find help on our
[discussion board](https://discuss.yjs.dev).
## Sponsors
I'm currently looking for sponsors that allow me to be less dependent on
contracting work. These awesome backers already fund further development of
Yjs:
[![Vincent Waller](https://github.com/vwall.png?size=60)](https://github.com/vwall)
[<img src="https://user-images.githubusercontent.com/5553757/83337333-a7bcb380-a2ba-11ea-837b-e404eb35d318.png"
height="60px" />](https://input.com/)
[![Duane Johnson](https://github.com/canadaduane.png?size=60)](https://github.com/canadaduane)
[![Joe Reeve](https://github.com/ISNIT0.png?size=60)](https://github.com/ISNIT0)
[<img src="https://room.sh/img/icons/android-chrome-192x192.png" height="60px" />](https://room.sh/)
[![JourneyApps](https://github.com/journeyapps.png?size=60)](https://github.com/journeyapps)
[![Adam Brunnmeier](https://github.com/adabru.png?size=60)](https://github.com/adabru)
[![Nathanael Anderson](https://github.com/NathanaelA.png?size=60)](https://github.com/NathanaelA)
Sponsorship also comes with special perks! [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad)
## Who is using Yjs
* [Relm](http://www.relm.us/) A collaborative gameworld for teamwork and
community. :star2:
* [Input](https://input.com/) A collaborative note taking app. :star2:
* [Room.sh](https://room.sh/) A meeting application with integrated
collaborative drawing, editing, and coding tools. :star:
* [http://coronavirustechhandbook.com/](https://coronavirustechhandbook.com/)
A collaborative wiki that is edited by thousands of different people to work
on a rapid and sophisticated response to the coronavirus outbreak and
subsequent impacts. :star:
* [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by
Nimbus Web.
* [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.
## Table of Contents ## Table of Contents
@@ -38,12 +77,6 @@ collaborative or distributed applications ping us at <yjs@tag1consulting.com>.
* [Miscellaneous](#Miscellaneous) * [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations) * [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) * [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
* [Evaluation](#Evaluation)
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
* [CRDT Algorithms](#CRDT-Algorithms)
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
* [License and Author](#License-and-Author) * [License and Author](#License-and-Author)
## Overview ## Overview
@@ -56,13 +89,10 @@ are implemented in separate modules.
| Name | Cursors | Binding | Demo | | Name | Cursors | Binding | Demo |
|---|:-:|---|---| |---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) | | [ProseMirror](https://prosemirror.net/) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://demos.yjs.dev/prosemirror/prosemirror.html) |
| [Quill](https://quilljs.com/) | ✔ | [y-quill](http://github.com/yjs/y-quill) | [demo](https://yjs-demos.now.sh/quill/) | | [Quill](https://quilljs.com/) | ✔ | [y-quill](http://github.com/yjs/y-quill) | [demo](https://demos.yjs.dev/quill/quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) | | [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) | | [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/yjs/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/yjs/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/yjs/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
### Providers ### Providers
@@ -85,6 +115,12 @@ document private.
A module that contains a simple websocket backend and a websocket client that A module that contains a simple websocket backend and a websocket client that
connects to that backend. The backend can be extended to persist updates in a connects to that backend. The backend can be extended to persist updates in a
leveldb database. leveldb database.
</dd>
<dt><a href="http://github.com/yjs/y-indexeddb">y-indexeddb</a></dt>
<dd>
Efficiently persists document updates to the browsers indexeddb database.
The document is immediately available and only diffs need to be synced through the
network provider.
</dd> </dd>
<dt><a href="http://github.com/yjs/y-dat">y-dat</a></dt> <dt><a href="http://github.com/yjs/y-dat">y-dat</a></dt>
<dd> <dd>
@@ -138,6 +174,54 @@ Now you understand how types are defined on a shared document. Next you can jump
to the [demo repository](https://github.com/yjs/yjs-demos) or continue reading to the [demo repository](https://github.com/yjs/yjs-demos) or continue reading
the API docs. the API docs.
### Example: Using and combining providers
Any of the Yjs providers can be combined with each other. So you can sync data
over different network technologies.
In most cases you want to use a network provider (like y-websocket or y-webrtc)
in combination with a persistence provider (y-indexeddb in the browser).
Persistence allows you to load the document faster and to persist data that is
created while offline.
For the sake of this demo we combine two different network providers with a
persistence provider.
```js
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// this allows you to instantly get the (cached) documents data
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
idbP.whenSynced.then(() => {
console.log('loaded data from indexed db')
})
// Sync clients with the y-webrtc provider.
const webrtcProvider = new WebrtcProvider('count-demo', ydoc)
// Sync clients with the y-websocket provider
const websocketProvider = new WebsocketProvider(
'wss://demos.yjs.dev', 'count-demo', ydoc
)
// array of numbers which produce a sum
const yarray = ydoc.getArray('count')
// observe changes of the sum
yarray.observe(event => {
// print updates when the data changes
console.log('new sum: ' + yarray.toArray().reduce((a,b) => a + b))
})
// add 1 to the sum
yarray.push([1]) // => "new sum: 1"
```
## API ## API
```js ```js
@@ -159,11 +243,13 @@ necessary.
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b> <b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd> <dd>
Insert content at <var>index</var>. Note that content is an array of elements. Insert content at <var>index</var>. Note that content is an array of elements.
I.e. <code>array.insert(0, [1]</code> splices the list and inserts 1 at I.e. <code>array.insert(0, [1])</code> splices the list and inserts 1 at
position 0. position 0.
</dd> </dd>
<b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b> <b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd> <dd></dd>
<b><code>unshift(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b> <b><code>delete(index:number, length:number)</code></b>
<dd></dd> <dd></dd>
<b><code>get(index:number)</code></b> <b><code>get(index:number)</code></b>
@@ -309,8 +395,12 @@ YTextEvents compute changes as deltas.
<dd></dd> <dd></dd>
<b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b> <b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b>
<dd>Assign formatting attributes to a range in the text</dd> <dd>Assign formatting attributes to a range in the text</dd>
<b><code>applyDelta(delta)</code></b> <b><code>applyDelta(delta, opts:Object&lt;string,any&gt;)</code></b>
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd> <dd>
See <a href="https://quilljs.com/docs/delta/">Quill Delta</a>
Can set options for preventing remove ending newLines, default is true.
<pre>ytext.applyDelta(delta, { sanitize: false })</pre>
</dd>
<b><code>length:number</code></b> <b><code>length:number</code></b>
<dd></dd> <dd></dd>
<b><code>toString():string</code></b> <b><code>toString():string</code></b>
@@ -464,6 +554,12 @@ const doc = new Y.Doc()
<dl> <dl>
<b><code>clientID</code></b> <b><code>clientID</code></b>
<dd>A unique id that identifies this client. (readonly)</dd> <dd>A unique id that identifies this client. (readonly)</dd>
<b><code>gc</code></b>
<dd>
Whether garbage collection is enabled on this doc instance. Set `doc.gc = false`
in order to disable gc and be able to restore old content. See https://github.com/yjs/yjs#yjs-crdt-algorithm
for more information about gc in Yjs.
</dd>
<b><code>transact(function(Transaction):void [, origin:any])</code></b> <b><code>transact(function(Transaction):void [, origin:any])</code></b>
<dd> <dd>
Every change on the shared document happens in a transaction. Observer calls and Every change on the shared document happens in a transaction. Observer calls and
@@ -640,7 +736,7 @@ Yjs ships with an Undo/Redo manager for selective undo/redo of of changes on a
Yjs type. The changes can be optionally scoped to transaction origins. Yjs type. The changes can be optionally scoped to transaction origins.
```js ```js
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext) const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc') ytext.insert(0, 'abc')
@@ -651,8 +747,8 @@ ytext.toString() // => 'abc'
``` ```
<dl> <dl>
<b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;, <b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;
[[{captureTimeout:number,trackedOrigins:Set&lt;any&gt;,deleteFilter:function(item):boolean}]])</code></b> [, {captureTimeout:number,trackedOrigins:Set&lt;any&gt;,deleteFilter:function(item):boolean}])</code></b>
<dd>Accepts either single type as scope or an array of types.</dd> <dd>Accepts either single type as scope or an array of types.</dd>
<b><code>undo()</code></b> <b><code>undo()</code></b>
<dd></dd> <dd></dd>
@@ -692,28 +788,30 @@ StackItem won't be merged.
// without stopCapturing // without stopCapturing
ytext.insert(0, 'a') ytext.insert(0, 'a')
ytext.insert(1, 'b') ytext.insert(1, 'b')
um.undo() undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed) ytext.toString() // => '' (note that 'ab' was removed)
// with stopCapturing // with stopCapturing
ytext.insert(0, 'a') ytext.insert(0, 'a')
um.stopCapturing() undoManager.stopCapturing()
ytext.insert(0, 'b') ytext.insert(0, 'b')
um.undo() undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed) ytext.toString() // => 'a' (note that only 'b' was removed)
``` ```
#### Example: Specify tracked origins #### Example: Specify tracked origins
Every change on the shared document has an origin. If no origin was specified, Every change on the shared document has an origin. If no origin was specified,
it defaults to `null`. By specifying `trackedTransactionOrigins` you can it defaults to `null`. By specifying `trackedOrigins` you can
selectively specify which changes should be tracked by `UndoManager`. The selectively specify which changes should be tracked by `UndoManager`. The
UndoManager instance is always added to `trackedTransactionOrigins`. UndoManager instance is always added to `trackedOrigins`.
```js ```js
class CustomBinding {} class CustomBinding {}
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
ytext.insert(0, 'abc') ytext.insert(0, 'abc')
undoManager.undo() undoManager.undo()
@@ -750,8 +848,10 @@ additional meta information like the cursor location or the view on the
document. You can assign meta-information to Undo-/Redo-StackItems. document. You can assign meta-information to Undo-/Redo-StackItems.
```js ```js
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
undoManager.on('stack-item-added', event => { undoManager.on('stack-item-added', event => {
// save the current cursor location on the stack-item // save the current cursor location on the stack-item

View File

@@ -1,305 +0,0 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
Yjs is a framework for offline-first p2p shared editing on structured data like
text, richtext, json, or XML. It is fairly easy to get started, as Yjs hides
most of the complexity of concurrent editing. For additional information, demos,
and tutorials visit [y-js.org](http://y-js.org/).
:warning: Checkout the [v13 docs](./README.md) for the upcoming release :warning:
### Extensions
Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
* *Connector* - a communication protocol that propagates changes to the clients
* *Database* - a database to store your changes
* one or more *Types* - that represent the shared data
Connectors, Databases, and Types are available as modules that extend Yjs. Here
is a list of the modules we know of:
##### Connectors
|Name | Description |
|----------------|-----------------------------------|
|[webrtc](https://github.com/y-js/y-webrtc) | Propagate updates Browser2Browser via WebRTC|
|[websockets](https://github.com/y-js/y-websockets-client) | Set up [a central server](https://github.com/y-js/y-websockets-client), and connect to it via websockets |
|[xmpp](https://github.com/y-js/y-xmpp) | Propagate updates in a XMPP multi-user-chat room ([XEP-0045](http://xmpp.org/extensions/xep-0045.html))|
|[ipfs](https://github.com/ipfs-labs/y-ipfs-connector) | Connector for the [Interplanetary File System](https://ipfs.io/)!|
|[test](https://github.com/y-js/y-test) | A Connector for testing purposes. It is designed to simulate delays that happen in worst case scenarios|
##### Database adapters
|Name | Description |
|----------------|-----------------------------------|
|[memory](https://github.com/y-js/y-memory) | In-memory storage. |
|[indexeddb](https://github.com/y-js/y-indexeddb) | Offline storage for the browser |
|[leveldb](https://github.com/y-js/y-leveldb) | Persistent storage for node apps |
##### Types
| Name | Description |
|----------|-------------------|
|[map](https://github.com/y-js/y-map) | A shared Map implementation. Maps from text to any stringify-able object |
|[array](https://github.com/y-js/y-array) | A shared Array implementation |
|[xml](https://github.com/y-js/y-xml) | An implementation of the DOM. You can create a two way binding to Browser DOM objects |
|[text](https://github.com/y-js/y-text) | Collaborate on text. Supports two way binding to the [Ace Editor](https://ace.c9.io), [CodeMirror](https://codemirror.net/), [Monaco](https://github.com/Microsoft/monaco-editor), textareas, input elements, and HTML elements (e.g. <*h1*>, or <*p*>) |
|[richtext](https://github.com/y-js/y-richtext) | Collaborate on rich text. Supports two way binding to the [Quill Rich Text Editor](http://quilljs.com/)|
##### Other
| Name | Description |
|-----------|-------------------|
|[y-element](http://y-js.org/y-element/) | Yjs Polymer Element |
## Use it!
Install Yjs, and its modules with [bower](http://bower.io/), or
[npm](https://www.npmjs.org/package/yjs).
### Bower
```
bower install --save yjs y-array % add all y-* modules you want to use
```
You only need to include the `y.js` file. Yjs is able to automatically require
missing modules.
```
<script src="./bower_components/yjs/y.js"></script>
```
### CDN
```
<script src="https://cdn.jsdelivr.net/npm/yjs@12/dist/y.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-array@10/dist/y-array.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-websockets-client@8/dist/y-websockets-client.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-memory@8/dist/y-memory.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-map@10/dist/y-map.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-text@9/dist/y-text.js"></script>
// ..
// do the same for all modules you want to use
```
### Npm
```
npm install --save yjs % add all y-* modules you want to use
```
If you don't include via script tag, you have to explicitly include all modules!
(Same goes for other module systems)
```
var Y = require('yjs')
require('y-array')(Y) // add the y-array type to Yjs
require('y-websockets-client')(Y)
require('y-memory')(Y)
require('y-map')(Y)
require('y-text')(Y)
// ..
// do the same for all modules you want to use
```
### ES6 Syntax
```
import Y from 'yjs'
import yArray from 'y-array'
import yWebsocketsClient from 'y-webrtc'
import yMemory from 'y-memory'
import yMap from 'y-map'
import yText from 'y-text'
// ..
Y.extend(yArray, yWebsocketsClient, yMemory, yArray, yMap, yText /*, .. */)
```
# Text editing example
Install dependencies
```
bower i yjs y-memory y-webrtc y-array y-text
```
Here is a simple example of a shared textarea
```HTML
<!DOCTYPE html>
<html>
<body>
<script src="./bower_components/yjs/y.js"></script>
<!-- Yjs automatically includes all missing dependencies (browser only) -->
<script>
Y({
db: {
name: 'memory' // use memory database adapter.
// name: 'indexeddb' // use indexeddb database adapter instead for offline apps
},
connector: {
name: 'webrtc', // use webrtc connector
// name: 'websockets-client'
// name: 'xmpp'
room: 'my-room' // clients connecting to the same room share data
},
sourceDir: './bower_components', // location of the y-* modules (browser only)
share: {
textarea: 'Text' // y.share.textarea is of type y-text
}
}).then(function (y) {
// The Yjs instance `y` is available
// y.share.* contains the shared types
// Bind `y.share.textarea` to `<textarea/>`
y.share.textarea.bind(document.querySelector('textarea'))
})
</script>
<textarea></textarea>
</body>
</html>
```
## Get Help & Give Help
There are some friendly people on [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/y-js/yjs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) who are eager to help, and answer questions. Please join!
Report _any_ issues to the
[Github issue page](https://github.com/y-js/yjs/issues)! I try to fix them very
soon, if possible.
# API
### Y(options)
* Y.extend(module1, module2, ..)
* Add extensions to Y
* `Y.extend(require('y-webrtc'))` has the same semantics as
`require('y-webrtc')(Y)`
* options.db
* Will be forwarded to the database adapter. Specify the database adaper on
`options.db.name`.
* Have a look at the used database adapter repository to see all available
options.
* options.connector
* Will be forwarded to the connector adapter. Specify the connector adaper on
`options.connector.name`.
* All our connectors implement a `room` property. Clients that specify the
same room share the same data.
* All of our connectors specify an `url` property that defines the connection
endpoint of the used connector.
* All of our connectors also have a default connection endpoint that you can
use for development.
* Set `options.connector.generateUserId = true` in order to genenerate a
userid, instead of receiving one from the server. This way the `Y(..)` is
immediately going to be resolved, without waiting for any confirmation from
the server. Use with caution.
* Have a look at the used connector repository to see all available options.
* *Only if you know what you are doing:* Set
`options.connector.preferUntransformed = true` in order receive the shared
data untransformed. This is very efficient as the database content is simply
copied to this client. This does only work if this client receives content
from only one client.
* options.sourceDir (browser only)
* Path where all y-* modules are stored
* Defaults to `/bower_components`
* Not required when running on `nodejs` / `iojs`
* When using nodejs you need to manually extend Yjs:
```
var Y = require('yjs')
// you have to require a db, connector, and *all* types you use!
require('y-memory')(Y)
require('y-webrtc')(Y)
require('y-map')(Y)
// ..
```
* options.share
* Specify on `options.share[arbitraryName]` types that are shared among all
users.
* E.g. Specify `options.share[arbitraryName] = 'Array'` to require y-array and
create an y-array type on `y.share[arbitraryName]`.
* If userA doesn't specify `options.share[arbitraryName]`, it won't be
available for userA.
* If userB specifies `options.share[arbitraryName]`, it still won't be
available for userA. But all the updates are send from userB to userA.
* In contrast to y-map, types on `y.share.*` cannot be overwritten or deleted.
Instead, they are merged among all users. This feature is only available on
`y.share.*`
* Weird behavior: It is supported that two users specify different types with
the same property name.
E.g. userA specifies `options.share.x = 'Array'`, and userB specifies
`options.share.x = 'Text'`. But they only share data if they specified the
same type with the same property name
* options.type (browser only)
* Array of modules that Yjs needs to require, before instantiating a shared
type.
* By default Yjs requires the specified database adapter, the specified
connector, and all modules that are used in `options.share.*`
* Put all types here that you intend to use, but are not used in y.share.*
### Instantiated Y object (y)
`Y(options)` returns a promise that is fulfilled when..
* All modules are loaded
* The specified database adapter is loaded
* The specified connector is loaded
* All types are included
* The connector is initialized, and a unique user id is set (received from the
server)
* Note: When using y-indexeddb, a retrieved user id is stored on `localStorage`
The promise returns an instance of Y. We denote it with a lower case `y`.
* y.share.*
* Instances of the types you specified on options.share.*
* y.share.* can only be defined once when you instantiate Y!
* y.connector is an instance of Y.AbstractConnector
* y.connector.onUserEvent(function (event) {..})
* Observe user events (event.action is either 'userLeft' or 'userJoined')
* y.connector.whenSynced(listener)
* `listener` is executed when y synced with at least one user.
* `listener` is not called when no other user is in the same room.
* y-websockets-client aways waits to sync with the server
* y.connector.disconnect()
* Force to disconnect this instance from the other instances
* y.connector.connect()
* Try to reconnect to the other instances (needs to be supported by the
connector)
* Not supported by y-xmpp
* y.close()
* Destroy this object.
* Destroys all types (they will throw weird errors if you still use them)
* Disconnects from the other instances (via connector)
* Returns a promise
* y.destroy()
* calls y.close()
* Removes all data from the database
* Returns a promise
* y.db.stopGarbageCollector()
* Stop the garbage collector. Call y.db.garbageCollect() to continue garbage
collection
* y.db.gc :: Boolean
* Whether gc is turned on
* y.db.gcTimeout :: Number (defaults to 50000 ms)
* Time interval between two garbage collect cycles
* It is required that all instances exchanged all messages after two garbage
collect cycles (after 100000 ms per default)
* y.db.userId :: String
* The used user id for this client. **Never overwrite this**
### Logging
Yjs uses [debug](https://github.com/visionmedia/debug) for logging. The flag
`y*` enables logging for all y-* components. You can selectively remove
components you are not interested in: E.g. The flag `y*,-y:connector-message`
will not log the long `y:connector-message` messages.
##### Enable logging in Node.js
```sh
DEBUG=y* node app.js
```
Remove the colors in order to log to a file:
```sh
DEBUG_COLORS=0 DEBUG=y* node app.js > log
```
##### Enable logging in the browser
```js
localStorage.debug = 'y*'
```
## License
Yjs is licensed under the [MIT License](./LICENSE).

935
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,24 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.0", "version": "13.1.1",
"description": "Shared Editing Library", "description": "Shared Editing Library",
"main": "./dist/yjs.cjs", "main": "./dist/yjs.cjs",
"module": "./src/index.js", "module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts", "types": "./dist/src/index.d.ts",
"sideEffects": false, "sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"scripts": { "scripts": {
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50", "test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000", "test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
"dist": "rm -rf dist && rollup -c && tsc", "dist": "rm -rf dist && rollup -c && tsc",
"watch": "rollup -wc", "watch": "rollup -wc",
"lint": "markdownlint README.md && standard && tsc", "lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true", "docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && http-server ./docs/", "serve-docs": "npm run docs && http-server ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000", "preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000",
"postversion": "git push && git push --tags",
"debug": "concurrently 'http-server -o test.html' 'npm run watch'", "debug": "concurrently 'http-server -o test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs", "trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs" "trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
@@ -57,20 +60,20 @@
}, },
"homepage": "https://yjs.dev", "homepage": "https://yjs.dev",
"dependencies": { "dependencies": {
"lib0": "^0.2.7" "lib0": "^0.2.27"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^11.0.1", "@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.0.0", "@rollup/plugin-node-resolve": "^7.1.3",
"concurrently": "^3.6.1", "concurrently": "^3.6.1",
"http-server": "^0.12.1", "http-server": "^0.12.3",
"jsdoc": "^3.6.3", "jsdoc": "^3.6.4",
"markdownlint-cli": "^0.19.0", "markdownlint-cli": "^0.23.1",
"rollup": "^1.29.1", "rollup": "^1.32.1",
"rollup-cli": "^1.0.9", "rollup-cli": "^1.0.9",
"standard": "^14.0.0", "standard": "^14.3.4",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^3.7.5", "typescript": "^3.9.3",
"y-protocols": "^0.2.0" "y-protocols": "^0.2.3"
} }
} }

View File

@@ -51,6 +51,15 @@ export default [{
} }
}, },
external: id => /^lib0\//.test(id) external: id => /^lib0\//.test(id)
}, {
input: './src/index.js',
output: {
name: 'Y',
file: 'dist/yjs.mjs',
format: 'esm',
sourcemap: true
},
external: id => /^lib0\//.test(id)
}, { }, {
input: './tests/index.js', input: './tests/index.js',
output: { output: {
@@ -62,7 +71,6 @@ export default [{
plugins: [ plugins: [
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
sourcemap: true,
mainFields: ['module', 'browser', 'main'] mainFields: ['module', 'browser', 'main']
}), }),
commonjs() commonjs()
@@ -78,7 +86,6 @@ export default [{
plugins: [ plugins: [
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
sourcemap: true,
mainFields: ['module', 'main'] mainFields: ['module', 'main']
}), }),
commonjs() commonjs()

View File

@@ -26,6 +26,7 @@ export {
ContentType, ContentType,
AbstractType, AbstractType,
RelativePosition, RelativePosition,
getTypeChildren,
createRelativePositionFromTypeIndex, createRelativePositionFromTypeIndex,
createRelativePositionFromJSON, createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition, createAbsolutePositionFromRelativePosition,
@@ -47,6 +48,7 @@ export {
typeMapGetSnapshot, typeMapGetSnapshot,
iterateDeletedStructs, iterateDeletedStructs,
applyUpdate, applyUpdate,
readUpdate,
encodeStateAsUpdate, encodeStateAsUpdate,
encodeStateVector, encodeStateVector,
UndoManager, UndoManager,
@@ -55,5 +57,7 @@ export {
isDeleted, isDeleted,
isParentOf, isParentOf,
equalSnapshots, equalSnapshots,
PermanentUserData // @TODO experimental PermanentUserData, // @TODO experimental
tryGc,
transact
} from './internals.js' } from './internals.js'

View File

@@ -6,20 +6,12 @@ import {
import * as encoding from 'lib0/encoding.js' // eslint-disable-line import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
/**
* @private
*/
export class AbstractStruct { export class AbstractStruct {
/** /**
* @param {ID} id * @param {ID} id
* @param {number} length * @param {number} length
*/ */
constructor (id, length) { constructor (id, length) {
/**
* The uniqe identifier of this struct.
* @type {ID}
* @readonly
*/
this.id = id this.id = id
this.length = length this.length = length
this.deleted = false this.deleted = false
@@ -40,7 +32,6 @@ export class AbstractStruct {
* @param {encoding.Encoder} encoder The encoder to write data to. * @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset * @param {number} offset
* @param {number} encodingRef * @param {number} encodingRef
* @private
*/ */
write (encoder, offset, encodingRef) { write (encoder, offset, encodingRef) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
@@ -54,23 +45,16 @@ export class AbstractStruct {
} }
} }
/**
* @private
*/
export class AbstractStructRef { export class AbstractStructRef {
/** /**
* @param {ID} id * @param {ID} id
*/ */
constructor (id) { constructor (id) {
this.id = id
/** /**
* @type {Array<ID>} * @type {Array<ID>}
*/ */
this._missing = [] this._missing = []
/**
* The uniqe identifier of this type.
* @type {ID}
*/
this.id = id
} }
/** /**

View File

@@ -5,9 +5,6 @@ import {
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentAny { export class ContentAny {
/** /**
* @param {Array<any>} arr * @param {Array<any>} arr
@@ -101,8 +98,6 @@ export class ContentAny {
} }
/** /**
* @private
*
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @return {ContentAny} * @return {ContentAny}
*/ */

View File

@@ -7,9 +7,6 @@ import * as decoding from 'lib0/decoding.js'
import * as buffer from 'lib0/buffer.js' import * as buffer from 'lib0/buffer.js'
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
/**
* @private
*/
export class ContentBinary { export class ContentBinary {
/** /**
* @param {Uint8Array} content * @param {Uint8Array} content
@@ -92,8 +89,6 @@ export class ContentBinary {
} }
/** /**
* @private
*
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @return {ContentBinary} * @return {ContentBinary}
*/ */

View File

@@ -7,9 +7,6 @@ import {
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentDeleted { export class ContentDeleted {
/** /**
* @param {number} len * @param {number} len

View File

@@ -95,8 +95,6 @@ export class ContentFormat {
} }
/** /**
* @private
*
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @return {ContentFormat} * @return {ContentFormat}
*/ */

View File

@@ -7,7 +7,7 @@ import {
readYXmlFragment, readYXmlFragment,
readYXmlHook, readYXmlHook,
readYXmlText, readYXmlText,
StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line ID, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line import * as encoding from 'lib0/encoding.js' // eslint-disable-line
@@ -115,7 +115,7 @@ export class ContentType {
// We try to merge all deleted items after each transaction, // We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged // 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 // since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
transaction._mergeStructs.add(item.id) transaction._mergeStructs.push(item)
} }
item = item.right item = item.right
} }
@@ -124,7 +124,7 @@ export class ContentType {
item.delete(transaction) item.delete(transaction)
} else { } else {
// same as above // same as above
transaction._mergeStructs.add(item.id) transaction._mergeStructs.push(item)
} }
}) })
transaction.changed.delete(this.type) transaction.changed.delete(this.type)

View File

@@ -2,7 +2,6 @@
import { import {
AbstractStructRef, AbstractStructRef,
AbstractStruct, AbstractStruct,
createID,
addStruct, addStruct,
StructStore, Transaction, ID // eslint-disable-line StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -78,8 +77,7 @@ export class GCRef extends AbstractStructRef {
*/ */
toStruct (transaction, store, offset) { toStruct (transaction, store, offset) {
if (offset > 0) { if (offset > 0) {
// @ts-ignore this.id.clock += offset
this.id = createID(this.id.client, this.id.clock + offset)
this.length -= offset this.length -= offset
} }
return new GC( return new GC(

View File

@@ -1,10 +1,9 @@
import { import {
readID, readID,
createID,
writeID, writeID,
GC, GC,
nextID, getState,
AbstractStructRef, AbstractStructRef,
AbstractStruct, AbstractStruct,
replaceStruct, replaceStruct,
@@ -21,6 +20,7 @@ import {
readContentAny, readContentAny,
readContentString, readContentString,
readContentEmbed, readContentEmbed,
createID,
readContentFormat, readContentFormat,
readContentType, readContentType,
addChangedTypeToTransaction, addChangedTypeToTransaction,
@@ -68,10 +68,11 @@ export const followRedone = (store, id) => {
* sending it to other peers * sending it to other peers
* *
* @param {Item|null} item * @param {Item|null} item
* @param {boolean} keep
*/ */
export const keepItem = item => { export const keepItem = (item, keep) => {
while (item !== null && !item.keep) { while (item !== null && item.keep !== keep) {
item.keep = true item.keep = keep
item = item.parent._item item = item.parent._item
} }
} }
@@ -87,12 +88,12 @@ export const keepItem = item => {
* @private * @private
*/ */
export const splitItem = (transaction, leftItem, diff) => { export const splitItem = (transaction, leftItem, diff) => {
const id = leftItem.id
// create rightItem // create rightItem
const { client, clock } = leftItem.id
const rightItem = new Item( const rightItem = new Item(
createID(id.client, id.clock + diff), createID(client, clock + diff),
leftItem, leftItem,
createID(id.client, id.clock + diff - 1), createID(client, clock + diff - 1),
leftItem.right, leftItem.right,
leftItem.rightOrigin, leftItem.rightOrigin,
leftItem.parent, leftItem.parent,
@@ -115,7 +116,7 @@ export const splitItem = (transaction, leftItem, diff) => {
rightItem.right.left = rightItem rightItem.right.left = rightItem
} }
// right is more specific. // right is more specific.
transaction._mergeStructs.add(rightItem.id) transaction._mergeStructs.push(rightItem)
// update parent._map // update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) { if (rightItem.parentSub !== null && rightItem.right === null) {
rightItem.parent._map.set(rightItem.parentSub, rightItem) rightItem.parent._map.set(rightItem.parentSub, rightItem)
@@ -136,8 +137,12 @@ export const splitItem = (transaction, leftItem, diff) => {
* @private * @private
*/ */
export const redoItem = (transaction, item, redoitems) => { export const redoItem = (transaction, item, redoitems) => {
if (item.redone !== null) { const doc = transaction.doc
return getItemCleanStart(transaction, item.redone) const store = doc.store
const ownClientID = doc.clientID
const redone = item.redone
if (redone !== null) {
return getItemCleanStart(transaction, redone)
} }
let parentItem = item.parent._item let parentItem = item.parent._item
/** /**
@@ -157,7 +162,7 @@ export const redoItem = (transaction, item, redoitems) => {
left = item left = item
while (left.right !== null) { while (left.right !== null) {
left = left.right left = left.right
if (left.id.client !== transaction.doc.clientID) { if (left.id.client !== ownClientID) {
// It is not possible to redo this item because it conflicts with a // It is not possible to redo this item because it conflicts with a
// change from another client // change from another client
return null return null
@@ -211,16 +216,18 @@ export const redoItem = (transaction, item, redoitems) => {
right = right.right right = right.right
} }
} }
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)
const redoneItem = new Item( const redoneItem = new Item(
nextID(transaction), nextId,
left, left === null ? null : left.lastId, left, left && left.lastId,
right, right === null ? null : right.id, right, right && right.id,
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type, parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
item.parentSub, item.parentSub,
item.content.copy() item.content.copy()
) )
item.redone = redoneItem.id item.redone = nextId
keepItem(redoneItem) keepItem(redoneItem, true)
redoneItem.integrate(transaction) redoneItem.integrate(transaction)
return redoneItem return redoneItem
} }
@@ -293,31 +300,35 @@ export class Item extends AbstractStruct {
* @type {AbstractContent} * @type {AbstractContent}
*/ */
this.content = content this.content = content
this.length = content.getLength()
this.countable = content.isCountable()
/** /**
* If true, do not garbage collect this Item. * If true, do not garbage collect this Item.
*/ */
this.keep = false this.keep = false
} }
get countable () {
return this.content.isCountable()
}
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @private
*/ */
integrate (transaction) { integrate (transaction) {
const store = transaction.doc.store const store = transaction.doc.store
const id = this.id
const parent = this.parent const parent = this.parent
const parentSub = this.parentSub const parentSub = this.parentSub
const length = this.length const length = this.length
/**
* @type {Item|null}
*/
let left = this.left
/** /**
* @type {Item|null} * @type {Item|null}
*/ */
let o let o
// set o to the first conflicting item // set o to the first conflicting item
if (this.left !== null) { if (left !== null) {
o = this.left.right o = left.right
} else if (parentSub !== null) { } else if (parentSub !== null) {
o = parent._map.get(parentSub) || null o = parent._map.get(parentSub) || null
while (o !== null && o.left !== null) { while (o !== null && o.left !== null) {
@@ -343,14 +354,14 @@ export class Item extends AbstractStruct {
conflictingItems.add(o) conflictingItems.add(o)
if (compareIDs(this.origin, o.origin)) { if (compareIDs(this.origin, o.origin)) {
// case 1 // case 1
if (o.id.client < id.client) { if (o.id.client < this.id.client) {
this.left = o left = o
conflictingItems.clear() conflictingItems.clear()
} }
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) { } else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
// case 2 // case 2
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) { if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
this.left = o left = o
conflictingItems.clear() conflictingItems.clear()
} }
} else { } else {
@@ -358,11 +369,12 @@ export class Item extends AbstractStruct {
} }
o = o.right o = o.right
} }
this.left = left
// reconnect left/right + update parent map/start if necessary // reconnect left/right + update parent map/start if necessary
if (this.left !== null) { if (left !== null) {
const right = this.left.right const right = left.right
this.right = right this.right = right
this.left.right = this left.right = this
} else { } else {
let r let r
if (parentSub !== null) { if (parentSub !== null) {
@@ -381,9 +393,9 @@ export class Item extends AbstractStruct {
} else if (parentSub !== null) { } else if (parentSub !== null) {
// set as current parent value if right === null and this is parentSub // set as current parent value if right === null and this is parentSub
parent._map.set(parentSub, this) parent._map.set(parentSub, this)
if (this.left !== null) { if (left !== null) {
// this is the current attribute value of parent. delete right // this is the current attribute value of parent. delete right
this.left.delete(transaction) left.delete(transaction)
} }
} }
// adjust length of parent // adjust length of parent
@@ -402,7 +414,6 @@ export class Item extends AbstractStruct {
/** /**
* Returns the next non-deleted item * Returns the next non-deleted item
* @private
*/ */
get next () { get next () {
let n = this.right let n = this.right
@@ -414,7 +425,6 @@ export class Item extends AbstractStruct {
/** /**
* Returns the previous non-deleted item * Returns the previous non-deleted item
* @private
*/ */
get prev () { get prev () {
let n = this.left let n = this.left
@@ -485,8 +495,6 @@ export class Item extends AbstractStruct {
/** /**
* @param {StructStore} store * @param {StructStore} store
* @param {boolean} parentGCd * @param {boolean} parentGCd
*
* @private
*/ */
gc (store, parentGCd) { gc (store, parentGCd) {
if (!this.deleted) { if (!this.deleted) {
@@ -508,8 +516,6 @@ export class Item extends AbstractStruct {
* *
* @param {encoding.Encoder} encoder The encoder to write data to. * @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset * @param {number} offset
*
* @private
*/ */
write (encoder, offset) { write (encoder, offset) {
const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin
@@ -528,7 +534,8 @@ export class Item extends AbstractStruct {
} }
if (origin === null && rightOrigin === null) { if (origin === null && rightOrigin === null) {
const parent = this.parent const parent = this.parent
if (parent._item === null) { const parentItem = parent._item
if (parentItem === null) {
// parent type on y._map // parent type on y._map
// find the correct key // find the correct key
const ykey = findRootTypeKey(parent) const ykey = findRootTypeKey(parent)
@@ -536,7 +543,7 @@ export class Item extends AbstractStruct {
encoding.writeVarString(encoder, ykey) encoding.writeVarString(encoder, ykey)
} else { } else {
encoding.writeVarUint(encoder, 0) // write parent id encoding.writeVarUint(encoder, 0) // write parent id
writeID(encoder, parent._item.id) writeID(encoder, parentItem.id)
} }
if (parentSub !== null) { if (parentSub !== null) {
encoding.writeVarString(encoder, parentSub) encoding.writeVarString(encoder, parentSub)
@@ -705,10 +712,12 @@ export class ItemRef extends AbstractStructRef {
*/ */
this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
const missing = this._missing const missing = this._missing
if (this.left !== null) { // Only add items to missing if they don't preceed this item (indicating that it has already been added).
// @todo Creating missing items could be done outside this constructor
if (this.left !== null && this.left.client !== id.client) {
missing.push(this.left) missing.push(this.left)
} }
if (this.right !== null) { if (this.right !== null && this.right.client !== id.client) {
missing.push(this.right) missing.push(this.right)
} }
if (this.parent !== null) { if (this.parent !== null) {
@@ -729,11 +738,7 @@ export class ItemRef extends AbstractStructRef {
*/ */
toStruct (transaction, store, offset) { toStruct (transaction, store, offset) {
if (offset > 0) { if (offset > 0) {
/** this.id.clock += offset
* @type {ID}
*/
const id = this.id
this.id = createID(id.client, id.clock + offset)
this.left = createID(this.id.client, this.id.clock - 1) this.left = createID(this.id.client, this.id.clock - 1)
this.content = this.content.splice(offset) this.content = this.content.splice(offset)
this.length -= offset this.length -= offset
@@ -741,10 +746,11 @@ export class ItemRef extends AbstractStructRef {
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left) const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
const right = this.right === null ? null : getItemCleanStart(transaction, this.right) const right = this.right === null ? null : getItemCleanStart(transaction, this.right)
const parentId = this.parent
let parent = null let parent = null
let parentSub = this.parentSub let parentSub = this.parentSub
if (this.parent !== null) { if (parentId !== null) {
const parentItem = getItem(store, this.parent) const parentItem = getItem(store, parentId)
// Edge case: toStruct is called with an offset > 0. In this case left is defined. // Edge case: toStruct is called with an offset > 0. In this case left is defined.
// Depending in which order structs arrive, left may be GC'd and the parent not // Depending in which order structs arrive, left may be GC'd and the parent not
// deleted. This is why we check if left is GC'd. Strictly we don't have // deleted. This is why we check if left is GC'd. Strictly we don't have
@@ -773,9 +779,9 @@ export class ItemRef extends AbstractStructRef {
: new Item( : new Item(
this.id, this.id,
left, left,
this.left, left && left.lastId,
right, right,
this.right, right && right.id,
parent, parent,
parentSub, parentSub,
this.content this.content

View File

@@ -4,14 +4,14 @@ import {
callEventHandlerListeners, callEventHandlerListeners,
addEventHandlerListener, addEventHandlerListener,
createEventHandler, createEventHandler,
nextID, getState,
isVisible, isVisible,
ContentType, ContentType,
createID,
ContentAny, ContentAny,
ContentBinary, ContentBinary,
createID,
getItemCleanStart, getItemCleanStart,
Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line ID, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
@@ -19,10 +19,25 @@ import * as iterator from 'lib0/iterator.js'
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line import * as encoding from 'lib0/encoding.js' // eslint-disable-line
/**
* Accumulate all (list) children of a type and return them as an Array.
*
* @param {AbstractType<any>} t
* @return {Array<Item>}
*/
export const getTypeChildren = t => {
let s = t._start
const arr = []
while (s) {
arr.push(s)
s = s.right
}
return arr
}
/** /**
* Call event listeners with an event. This will also add an event to all * Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers). * parents (for `.observeDeep` handlers).
* @private
* *
* @template EventType * @template EventType
* @param {AbstractType<EventType>} type * @param {AbstractType<EventType>} type
@@ -54,17 +69,14 @@ export class AbstractType {
*/ */
this._item = null this._item = null
/** /**
* @private
* @type {Map<string,Item>} * @type {Map<string,Item>}
*/ */
this._map = new Map() this._map = new Map()
/** /**
* @private
* @type {Item|null} * @type {Item|null}
*/ */
this._start = null this._start = null
/** /**
* @private
* @type {Doc|null} * @type {Doc|null}
*/ */
this.doc = null this.doc = null
@@ -90,7 +102,6 @@ export class AbstractType {
* *
* @param {Doc} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {Item|null} item * @param {Item|null} item
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
this.doc = y this.doc = y
@@ -99,7 +110,6 @@ export class AbstractType {
/** /**
* @return {AbstractType<EventType>} * @return {AbstractType<EventType>}
* @private
*/ */
_copy () { _copy () {
throw error.methodUnimplemented() throw error.methodUnimplemented()
@@ -107,7 +117,6 @@ export class AbstractType {
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @private
*/ */
_write (encoder) { } _write (encoder) { }
@@ -128,8 +137,6 @@ export class AbstractType {
* *
* @param {Transaction} transaction * @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.
*
* @private
*/ */
_callObserver (transaction, parentSubs) { /* skip if no type is specified */ } _callObserver (transaction, parentSubs) { /* skip if no type is specified */ }
@@ -368,6 +375,9 @@ export const typeListGet = (type, index) => {
*/ */
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => { export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
let left = referenceItem let left = referenceItem
const doc = transaction.doc
const ownClientId = doc.clientID
const store = doc.store
const right = referenceItem === null ? parent._start : referenceItem.right const right = referenceItem === null ? parent._start : referenceItem.right
/** /**
* @type {Array<Object|Array<any>|number>} * @type {Array<Object|Array<any>|number>}
@@ -375,7 +385,7 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
let jsonContent = [] let jsonContent = []
const packJsonContent = () => { const packJsonContent = () => {
if (jsonContent.length > 0) { if (jsonContent.length > 0) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentAny(jsonContent)) left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction) left.integrate(transaction)
jsonContent = [] jsonContent = []
} }
@@ -394,12 +404,12 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
switch (c.constructor) { switch (c.constructor) {
case Uint8Array: case Uint8Array:
case ArrayBuffer: case ArrayBuffer:
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c)))) left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(transaction) left.integrate(transaction)
break break
default: default:
if (c instanceof AbstractType) { if (c instanceof AbstractType) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentType(c)) left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
left.integrate(transaction) left.integrate(transaction)
} else { } else {
throw new Error('Unexpected content type in insert operation') throw new Error('Unexpected content type in insert operation')
@@ -502,6 +512,8 @@ export const typeMapDelete = (transaction, parent, key) => {
*/ */
export const typeMapSet = (transaction, parent, key, value) => { export const typeMapSet = (transaction, parent, key, value) => {
const left = parent._map.get(key) || null const left = parent._map.get(key) || null
const doc = transaction.doc
const ownClientId = doc.clientID
let content let content
if (value == null) { if (value == null) {
content = new ContentAny([value]) content = new ContentAny([value])
@@ -525,7 +537,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
} }
} }
} }
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction) new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction)
} }
/** /**

View File

@@ -40,7 +40,7 @@ export class YArrayEvent extends YEvent {
* A shared Array implementation. * A shared Array implementation.
* @template T * @template T
* @extends AbstractType<YArrayEvent<T>> * @extends AbstractType<YArrayEvent<T>>
* @implements {IterableIterator<T>} * @implements {Iterable<T>}
*/ */
export class YArray extends AbstractType { export class YArray extends AbstractType {
constructor () { constructor () {
@@ -61,8 +61,6 @@ export class YArray extends AbstractType {
* *
* @param {Doc} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {Item} item * @param {Item} item
*
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
@@ -83,8 +81,6 @@ export class YArray extends AbstractType {
* *
* @param {Transaction} transaction * @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.
*
* @private
*/ */
_callObserver (transaction, parentSubs) { _callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YArrayEvent(this, transaction)) callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
@@ -125,6 +121,15 @@ export class YArray extends AbstractType {
this.insert(this.length, content) this.insert(this.length, content)
} }
/**
* Preppends content to this YArray.
*
* @param {Array<T>} content Array of content to preppend.
*/
unshift (content) {
this.insert(0, content)
}
/** /**
* Deletes elements starting from an index. * Deletes elements starting from an index.
* *
@@ -200,7 +205,6 @@ export class YArray extends AbstractType {
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @private
*/ */
_write (encoder) { _write (encoder) {
encoding.writeVarUint(encoder, YArrayRefID) encoding.writeVarUint(encoder, YArrayRefID)

View File

@@ -42,7 +42,7 @@ export class YMapEvent extends YEvent {
* A shared Map implementation. * A shared Map implementation.
* *
* @extends AbstractType<YMapEvent<T>> * @extends AbstractType<YMapEvent<T>>
* @implements {IterableIterator} * @implements {Iterable<T>}
*/ */
export class YMap extends AbstractType { export class YMap extends AbstractType {
constructor () { constructor () {
@@ -63,8 +63,6 @@ export class YMap extends AbstractType {
* *
* @param {Doc} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {Item} item * @param {Item} item
*
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
@@ -83,8 +81,6 @@ export class YMap extends AbstractType {
* *
* @param {Transaction} transaction * @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.
*
* @private
*/ */
_callObserver (transaction, parentSubs) { _callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs)) callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs))
@@ -215,8 +211,6 @@ export class YMap extends AbstractType {
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
*
* @private
*/ */
_write (encoder) { _write (encoder) {
encoding.writeVarUint(encoder, YMapRefID) encoding.writeVarUint(encoder, YMapRefID)

View File

@@ -6,23 +6,27 @@
import { import {
YEvent, YEvent,
AbstractType, AbstractType,
nextID,
createID,
getItemCleanStart, getItemCleanStart,
getState,
isVisible, isVisible,
createID,
YTextRefID, YTextRefID,
callTypeObservers, callTypeObservers,
transact, transact,
ContentEmbed, ContentEmbed,
GC,
ContentFormat, ContentFormat,
ContentString, ContentString,
splitSnapshotAffectedStructs, splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as object from 'lib0/object.js' import * as object from 'lib0/object.js'
import * as map from 'lib0/map.js'
/** /**
* @param {any} a * @param {any} a
@@ -146,8 +150,10 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
left = right left = right
right = right.right right = right.right
} }
const doc = transaction.doc
const ownClientId = doc.clientID
for (const [key, val] of negatedAttributes) { for (const [key, val] of negatedAttributes) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val)) left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction) left.integrate(transaction)
} }
return { left, right } return { left, right }
@@ -211,6 +217,8 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
* @function * @function
**/ **/
const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => { const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
const negatedAttributes = new Map() const negatedAttributes = new Map()
// insert format-start items // insert format-start items
for (const key in attributes) { for (const key in attributes) {
@@ -219,7 +227,7 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
if (!equalAttrs(currentVal, val)) { if (!equalAttrs(currentVal, val)) {
// save negated attribute (set null if currentVal undefined) // save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal) negatedAttributes.set(key, currentVal)
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val)) left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction) left.integrate(transaction)
} }
} }
@@ -245,13 +253,15 @@ const insertText = (transaction, parent, left, right, currentAttributes, text, a
attributes[key] = null attributes[key] = null
} }
} }
const doc = transaction.doc
const ownClientId = doc.clientID
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes) const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes) const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
left = insertPos.left left = insertPos.left
right = insertPos.right right = insertPos.right
// insert content // insert content
const content = text.constructor === String ? new ContentString(text) : new ContentEmbed(text) const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : new ContentEmbed(text)
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, content) left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
left.integrate(transaction) left.integrate(transaction)
return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes) return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes)
} }
@@ -270,6 +280,8 @@ const insertText = (transaction, parent, left, right, currentAttributes, text, a
* @function * @function
*/ */
const formatText = (transaction, parent, left, right, currentAttributes, length, attributes) => { const formatText = (transaction, parent, left, right, currentAttributes, length, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes) const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes) const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
const negatedAttributes = insertPos.negatedAttributes const negatedAttributes = insertPos.negatedAttributes
@@ -314,12 +326,116 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
for (; length > 0; length--) { for (; length > 0; length--) {
newlines += '\n' newlines += '\n'
} }
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentString(newlines)) left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentString(newlines))
left.integrate(transaction) left.integrate(transaction)
} }
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes) return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
} }
/**
* Call this function after string content has been deleted in order to
* clean up formatting Items.
*
* @param {Transaction} transaction
* @param {Item} start
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startAttributes
* @param {Map<string,any>} endAttributes This attribute is modified!
* @return {number} The amount of formatting Items deleted.
*
* @function
*/
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
}
end = end.right
}
let cleanups = 0
while (start !== end) {
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) {
// Either this format is overwritten or it is not necessary because the attribute already existed.
start.delete(transaction)
cleanups++
}
break
}
}
}
start = /** @type {Item} */ (start.right)
}
return cleanups
}
/**
* @param {Transaction} transaction
* @param {Item | null} item
*/
const cleanupContextlessFormattingGap = (transaction, item) => {
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || (item.right.content.constructor !== ContentString && item.right.content.constructor !== ContentEmbed))) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || (item.content.constructor !== ContentString && item.content.constructor !== ContentEmbed))) {
if (!item.deleted && item.content.constructor === ContentFormat) {
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
item.delete(transaction)
} else {
attrs.add(key)
}
}
item = item.left
}
}
/**
* This function is experimental and subject to change / be removed.
*
* Ideally, we don't need this function at all. Formatting attributes should be cleaned up
* automatically after each change. This function iterates twice over the complete YText type
* and removes unnecessary formatting attributes. This is also helpful for testing.
*
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
*
* @param {YText} type
* @return {number} How many formatting attributes have been cleaned up.
*/
export const cleanupYTextFormatting = type => {
let res = 0
transact(/** @type {Doc} */ (type.doc), transaction => {
let start = /** @type {Item} */ (type._start)
let end = type._start
let startAttributes = map.create()
const currentAttributes = map.copy(startAttributes)
while (end) {
if (end.deleted === false) {
switch (end.content.constructor) {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
break
case ContentEmbed:
case ContentString:
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
startAttributes = map.copy(currentAttributes)
start = end
break
}
}
end = end.right
}
})
return res
}
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {Item|null} left * @param {Item|null} left
@@ -332,6 +448,8 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
* @function * @function
*/ */
const deleteText = (transaction, left, right, currentAttributes, length) => { const deleteText = (transaction, left, right, currentAttributes, length) => {
const startAttrs = map.copy(currentAttributes)
const start = right
while (length > 0 && right !== null) { while (length > 0 && right !== null) {
if (right.deleted === false) { if (right.deleted === false) {
switch (right.content.constructor) { switch (right.content.constructor) {
@@ -351,6 +469,9 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
left = right left = right
right = right.right right = right.right
} }
if (start) {
cleanupFormattingGap(transaction, start, right, startAttrs, map.copy(currentAttributes))
}
return { left, right } return { left, right }
} }
@@ -400,7 +521,6 @@ export class YTextEvent extends YEvent {
constructor (ytext, transaction) { constructor (ytext, transaction) {
super(ytext, transaction) super(ytext, transaction)
/** /**
* @private
* @type {Array<DeltaItem>|null} * @type {Array<DeltaItem>|null}
*/ */
this._delta = null this._delta = null
@@ -612,11 +732,15 @@ export class YText extends AbstractType {
/** /**
* Array of pending operations on this type * Array of pending operations on this type
* @type {Array<function():void>?} * @type {Array<function():void>?}
* @private
*/ */
this._pending = string !== undefined ? [() => this.insert(0, string)] : [] this._pending = string !== undefined ? [() => this.insert(0, string)] : []
} }
/**
* Number of characters of this text type.
*
* @type {number}
*/
get length () { get length () {
return this._length return this._length
} }
@@ -624,8 +748,6 @@ export class YText extends AbstractType {
/** /**
* @param {Doc} y * @param {Doc} y
* @param {Item} item * @param {Item} item
*
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
@@ -646,11 +768,50 @@ export class YText extends AbstractType {
* *
* @param {Transaction} transaction * @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.
*
* @private
*/ */
_callObserver (transaction, parentSubs) { _callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YTextEvent(this, transaction)) const event = new YTextEvent(this, transaction)
const doc = transaction.doc
// If a remote change happened, we try to cleanup potential formatting duplicates.
if (!transaction.local) {
// check if another formatting item was inserted
let foundFormattingItem = false
for (const [client, afterClock] of transaction.afterState) {
const clock = transaction.beforeState.get(client) || 0
if (afterClock === clock) {
continue
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
// @ts-ignore
if (!item.deleted && item.content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
if (foundFormattingItem) {
break
}
}
transact(doc, t => {
if (foundFormattingItem) {
// If a formatting item was inserted, we simply clean the whole type.
// We need to compute currentAttributes for the current position anyway.
cleanupYTextFormatting(this)
} else {
// If no formatting attribute was inserted, we can make due with contextless
// formatting cleanups.
// Contextless: it is not necessary to compute currentAttributes for the affected position.
iterateDeletedStructs(t, transaction.deleteSet, item => {
if (item instanceof GC) {
return
}
if (item.parent === this) {
cleanupContextlessFormattingGap(t, item)
}
})
}
})
}
callTypeObservers(this, transaction, event)
} }
/** /**
@@ -687,10 +848,13 @@ export class YText extends AbstractType {
* Apply a {@link Delta} on this shared YText type. * Apply a {@link Delta} on this shared YText type.
* *
* @param {any} delta The changes to apply on this element. * @param {any} delta The changes to apply on this element.
* @param {object} [opts]
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
*
* *
* @public * @public
*/ */
applyDelta (delta) { applyDelta (delta, { sanitize = true } = {}) {
if (this.doc !== null) { if (this.doc !== null) {
transact(this.doc, transaction => { transact(this.doc, transaction => {
/** /**
@@ -706,7 +870,7 @@ export class YText extends AbstractType {
// there is a newline at the end of the content. // there is a newline at the end of the content.
// If we omit this step, clients will see a different number of // If we omit this step, clients will see a different number of
// paragraphs, but nothing bad will happen. // paragraphs, but nothing bad will happen.
const ins = (typeof op.insert === 'string' && i === delta.length - 1 && pos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert const ins = (!sanitize && typeof op.insert === 'string' && i === delta.length - 1 && pos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
if (typeof ins !== 'string' || ins.length > 0) { if (typeof ins !== 'string' || ins.length > 0) {
pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, ins, op.attributes || {}) pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, ins, op.attributes || {})
} }
@@ -795,12 +959,24 @@ export class YText extends AbstractType {
str += /** @type {ContentString} */ (n.content).str str += /** @type {ContentString} */ (n.content).str
break break
} }
case ContentEmbed: case ContentEmbed: {
packStr() packStr()
ops.push({ /**
* @type {Object<string,any>}
*/
const op = {
insert: /** @type {ContentEmbed} */ (n.content).embed insert: /** @type {ContentEmbed} */ (n.content).embed
}) }
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
for (const [key, value] of currentAttributes) {
attrs[key] = value
}
}
ops.push(op)
break break
}
case ContentFormat: case ContentFormat:
if (isVisible(n, snapshot)) { if (isVisible(n, snapshot)) {
packStr() packStr()
@@ -905,6 +1081,9 @@ export class YText extends AbstractType {
* @public * @public
*/ */
format (index, length, attributes) { format (index, length, attributes) {
if (length === 0) {
return
}
const y = this.doc const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
@@ -921,8 +1100,6 @@ export class YText extends AbstractType {
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
*
* @private
*/ */
_write (encoder) { _write (encoder) {
encoding.writeVarUint(encoder, YTextRefID) encoding.writeVarUint(encoder, YTextRefID)

View File

@@ -27,7 +27,6 @@ export class YXmlElement extends YXmlFragment {
this.nodeName = nodeName this.nodeName = nodeName
/** /**
* @type {Map<string, any>|null} * @type {Map<string, any>|null}
* @private
*/ */
this._prelimAttrs = new Map() this._prelimAttrs = new Map()
} }
@@ -41,7 +40,6 @@ export class YXmlElement extends YXmlFragment {
* *
* @param {Doc} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {Item} item * @param {Item} item
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
@@ -55,7 +53,6 @@ export class YXmlElement extends YXmlFragment {
* Creates an Item with the same effect as this Item (without position effect) * Creates an Item with the same effect as this Item (without position effect)
* *
* @return {YXmlElement} * @return {YXmlElement}
* @private
*/ */
_copy () { _copy () {
return new YXmlElement(this.nodeName) return new YXmlElement(this.nodeName)
@@ -184,7 +181,6 @@ export class YXmlElement extends YXmlFragment {
* *
* This is called when this Item is sent to a remote peer. * This is called when this Item is sent to a remote peer.
* *
* @private
* @param {encoding.Encoder} encoder The encoder to write data to. * @param {encoding.Encoder} encoder The encoder to write data to.
*/ */
_write (encoder) { _write (encoder) {
@@ -197,7 +193,6 @@ export class YXmlElement extends YXmlFragment {
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @return {YXmlElement} * @return {YXmlElement}
* *
* @private
* @function * @function
*/ */
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder)) export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))

View File

@@ -48,7 +48,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
* Can be created with {@link YXmlFragment#createTreeWalker} * Can be created with {@link YXmlFragment#createTreeWalker}
* *
* @public * @public
* @implements {IterableIterator} * @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
*/ */
export class YXmlTreeWalker { export class YXmlTreeWalker {
/** /**
@@ -81,10 +81,10 @@ export class YXmlTreeWalker {
* @type {Item|null} * @type {Item|null}
*/ */
let n = this._currentNode let n = this._currentNode
let type = /** @type {ContentType} */ (n.content).type let type = /** @type {any} */ (n.content).type
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
do { do {
type = /** @type {ContentType} */ (n.content).type type = /** @type {any} */ (n.content).type
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) { if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
// walk down in the tree // walk down in the tree
n = type._start n = type._start
@@ -127,7 +127,6 @@ export class YXmlFragment extends AbstractType {
super() super()
/** /**
* @type {Array<any>|null} * @type {Array<any>|null}
* @private
*/ */
this._prelimContent = [] this._prelimContent = []
} }
@@ -141,7 +140,6 @@ export class YXmlFragment extends AbstractType {
* *
* @param {Doc} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {Item} item * @param {Item} item
* @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
@@ -224,7 +222,6 @@ export class YXmlFragment extends AbstractType {
/** /**
* Creates YXmlEvent and calls observers. * Creates YXmlEvent and calls observers.
* @private
* *
* @param {Transaction} transaction * @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.
@@ -328,7 +325,6 @@ export class YXmlFragment extends AbstractType {
* *
* This is called when this Item is sent to a remote peer. * This is called when this Item is sent to a remote peer.
* *
* @private
* @param {encoding.Encoder} encoder The encoder to write data to. * @param {encoding.Encoder} encoder The encoder to write data to.
*/ */
_write (encoder) { _write (encoder) {

View File

@@ -25,8 +25,6 @@ export class YXmlHook extends YMap {
/** /**
* Creates an Item with the same effect as this Item (without position effect) * Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/ */
_copy () { _copy () {
return new YXmlHook(this.hookName) return new YXmlHook(this.hookName)
@@ -69,8 +67,6 @@ export class YXmlHook extends YMap {
* This is called when this Item is sent to a remote peer. * This is called when this Item is sent to a remote peer.
* *
* @param {encoding.Encoder} encoder The encoder to write data to. * @param {encoding.Encoder} encoder The encoder to write data to.
*
* @private
*/ */
_write (encoder) { _write (encoder) {
super._write(encoder) super._write(encoder)

View File

@@ -79,8 +79,6 @@ export class YXmlText extends YText {
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
*
* @private
*/ */
_write (encoder) { _write (encoder) {
encoding.writeVarUint(encoder, YXmlTextRefID) encoding.writeVarUint(encoder, YXmlTextRefID)

View File

@@ -1,11 +1,11 @@
import { import {
findIndexSS, findIndexSS,
createID,
getState, getState,
splitItem, splitItem,
createID,
iterateStructs, iterateStructs,
Item, GC, StructStore, Transaction, ID // eslint-disable-line Item, AbstractStruct, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as array from 'lib0/array.js' import * as array from 'lib0/array.js'
@@ -42,7 +42,6 @@ export class DeleteSet {
constructor () { constructor () {
/** /**
* @type {Map<number,Array<DeleteItem>>} * @type {Map<number,Array<DeleteItem>>}
* @private
*/ */
this.clients = new Map() this.clients = new Map()
} }

View File

@@ -17,6 +17,8 @@ import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js' import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
export const generateNewClientId = random.uint32
/** /**
* A Yjs instance handles the state of shared data. * A Yjs instance handles the state of shared data.
* @extends Observable<string> * @extends Observable<string>
@@ -25,11 +27,13 @@ export class Doc extends Observable {
/** /**
* @param {Object} conf configuration * @param {Object} conf configuration
* @param {boolean} [conf.gc] Disable garbage collection (default: gc=true) * @param {boolean} [conf.gc] Disable garbage collection (default: gc=true)
* @param {function(Item):boolean} [conf.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
*/ */
constructor ({ gc = true } = {}) { constructor ({ gc = true, gcFilter = () => true } = {}) {
super() super()
this.gc = gc this.gc = gc
this.clientID = random.uint32() this.gcFilter = gcFilter
this.clientID = generateNewClientId()
/** /**
* @type {Map<string, AbstractType<YEvent>>} * @type {Map<string, AbstractType<YEvent>>}
*/ */
@@ -37,12 +41,10 @@ export class Doc extends Observable {
this.store = new StructStore() this.store = new StructStore()
/** /**
* @type {Transaction | null} * @type {Transaction | null}
* @private
*/ */
this._transaction = null this._transaction = null
/** /**
* @type {Array<Transaction>} * @type {Array<Transaction>}
* @private
*/ */
this._transactionCleanups = [] this._transactionCleanups = []
} }
@@ -103,6 +105,7 @@ export class Doc extends Observable {
t._map = type._map t._map = type._map
type._map.forEach(/** @param {Item?} n */ n => { type._map.forEach(/** @param {Item?} n */ n => {
for (; n !== null; n = n.left) { for (; n !== null; n = n.left) {
// @ts-ignore
n.parent = t n.parent = t
} }
}) })
@@ -168,8 +171,6 @@ export class Doc extends Observable {
/** /**
* Emit `destroy` event and unregister all event handlers. * Emit `destroy` event and unregister all event handlers.
*
* @protected
*/ */
destroy () { destroy () {
this.emit('destroyed', [true]) this.emit('destroyed', [true])

View File

@@ -1,12 +1,12 @@
import { import {
createID,
writeID, writeID,
readID, readID,
compareIDs, compareIDs,
getState, getState,
findRootTypeKey, findRootTypeKey,
Item, Item,
createID,
ContentType, ContentType,
followRedone, followRedone,
ID, Doc, AbstractType // eslint-disable-line ID, Doc, AbstractType // eslint-disable-line
@@ -107,7 +107,7 @@ export const createRelativePosition = (type, item) => {
if (type._item === null) { if (type._item === null) {
tname = findRootTypeKey(type) tname = findRootTypeKey(type)
} else { } else {
typeid = type._item.id typeid = createID(type._item.id.client, type._item.id.clock)
} }
return new RelativePosition(typeid, tname, item) return new RelativePosition(typeid, tname, item)
} }

View File

@@ -4,13 +4,13 @@ import {
createDeleteSetFromStructStore, createDeleteSetFromStructStore,
getStateVector, getStateVector,
getItemCleanStart, getItemCleanStart,
createID,
iterateDeletedStructs, iterateDeletedStructs,
writeDeleteSet, writeDeleteSet,
writeStateVector, writeStateVector,
readDeleteSet, readDeleteSet,
readStateVector, readStateVector,
createDeleteSet, createDeleteSet,
createID,
getState, getState,
Transaction, Doc, DeleteSet, Item // eslint-disable-line Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -28,13 +28,11 @@ export class Snapshot {
constructor (ds, sv) { constructor (ds, sv) {
/** /**
* @type {DeleteSet} * @type {DeleteSet}
* @private
*/ */
this.ds = ds this.ds = ds
/** /**
* State Map * State Map
* @type {Map<number,number>} * @type {Map<number,number>}
* @private
*/ */
this.sv = sv this.sv = sv
} }

View File

@@ -2,7 +2,7 @@
import { import {
GC, GC,
splitItem, splitItem,
GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line AbstractStruct, GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
@@ -13,7 +13,6 @@ export class StructStore {
constructor () { constructor () {
/** /**
* @type {Map<number,Array<GC|Item>>} * @type {Map<number,Array<GC|Item>>}
* @private
*/ */
this.clients = new Map() this.clients = new Map()
/** /**
@@ -23,19 +22,16 @@ export class StructStore {
* slow in Chrome for arrays with more than 100k elements * slow in Chrome for arrays with more than 100k elements
* @see tryResumePendingStructRefs * @see tryResumePendingStructRefs
* @type {Map<number,{i:number,refs:Array<GCRef|ItemRef>}>} * @type {Map<number,{i:number,refs:Array<GCRef|ItemRef>}>}
* @private
*/ */
this.pendingClientsStructRefs = new Map() this.pendingClientsStructRefs = new Map()
/** /**
* Stack of pending structs waiting for struct dependencies * Stack of pending structs waiting for struct dependencies
* Maximum length of stack is structReaders.size * Maximum length of stack is structReaders.size
* @type {Array<GCRef|ItemRef>} * @type {Array<GCRef|ItemRef>}
* @private
*/ */
this.pendingStack = [] this.pendingStack = []
/** /**
* @type {Array<decoding.Decoder>} * @type {Array<decoding.Decoder>}
* @private
*/ */
this.pendingDeleteReaders = [] this.pendingDeleteReaders = []
} }
@@ -118,7 +114,7 @@ export const addStruct = (store, struct) => {
/** /**
* Perform a binary search on a sorted array * Perform a binary search on a sorted array
* @param {Array<any>} structs * @param {Array<Item|GC>} structs
* @param {number} clock * @param {number} clock
* @return {number} * @return {number}
* *
@@ -128,10 +124,15 @@ export const addStruct = (store, struct) => {
export const findIndexSS = (structs, clock) => { export const findIndexSS = (structs, clock) => {
let left = 0 let left = 0
let right = structs.length - 1 let right = structs.length - 1
let mid = structs[right]
let midclock = mid.id.clock
if (mid.id.clock === clock) {
return right
}
let midindex = math.floor((clock / (midclock + mid.length)) * right) // pivoting the search
while (left <= right) { while (left <= right) {
const midindex = math.floor((left + right) / 2) mid = structs[midindex]
const mid = structs[midindex] midclock = mid.id.clock
const midclock = mid.id.clock
if (midclock <= clock) { if (midclock <= clock) {
if (clock < midclock + mid.length) { if (clock < midclock + mid.length) {
return midindex return midindex
@@ -140,6 +141,7 @@ export const findIndexSS = (structs, clock) => {
} else { } else {
right = midindex - 1 right = midindex - 1
} }
midindex = math.floor((left + right) / 2)
} }
// Always check state before looking for a struct in StructStore // Always check state before looking for a struct in StructStore
// Therefore the case of not finding a struct is unexpected // Therefore the case of not finding a struct is unexpected
@@ -167,16 +169,10 @@ export const find = (store, id) => {
/** /**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise. * Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private * @private
* @function * @function
*/ */
// @ts-ignore export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
export const getItem = (store, id) => find(store, id)
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction

View File

@@ -1,7 +1,6 @@
import { import {
getState, getState,
createID,
writeStructsFromTransaction, writeStructsFromTransaction,
writeDeleteSet, writeDeleteSet,
DeleteSet, DeleteSet,
@@ -10,13 +9,16 @@ import {
findIndexSS, findIndexSS,
callEventHandlerListeners, callEventHandlerListeners,
Item, Item,
ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line generateNewClientId,
createID,
GC, StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
import * as set from 'lib0/set.js' import * as set from 'lib0/set.js'
import * as logging from 'lib0/logging.js'
import { callAll } from 'lib0/function.js' import { callAll } from 'lib0/function.js'
/** /**
@@ -84,10 +86,9 @@ export class Transaction {
*/ */
this.changedParentTypes = new Map() this.changedParentTypes = new Map()
/** /**
* @type {Set<ID>} * @type {Array<AbstractStruct>}
* @private
*/ */
this._mergeStructs = new Set() this._mergeStructs = []
/** /**
* @type {any} * @type {any}
*/ */
@@ -145,6 +146,85 @@ export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
} }
} }
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
const tryGcDeleteSet = (ds, store, gcFilter) => {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
struct.gc(store, false)
}
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
*/
const tryMergeDeleteSet = (ds, store) => {
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (ds, store, gcFilter) => {
tryGcDeleteSet(ds, store, gcFilter)
tryMergeDeleteSet(ds, store)
}
/** /**
* @param {Array<Transaction>} transactionCleanups * @param {Array<Transaction>} transactionCleanups
* @param {number} i * @param {number} i
@@ -155,6 +235,7 @@ const cleanupTransactions = (transactionCleanups, i) => {
const doc = transaction.doc const doc = transaction.doc
const store = doc.store const store = doc.store
const ds = transaction.deleteSet const ds = transaction.deleteSet
const mergeStructs = transaction._mergeStructs
try { try {
sortAndMergeDeleteSet(ds) sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store) transaction.afterState = getStateVector(transaction.doc.store)
@@ -201,69 +282,18 @@ const cleanupTransactions = (transactionCleanups, i) => {
}) })
callAll(fs, []) callAll(fs, [])
} finally { } finally {
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
// Replace deleted items with ItemDeleted / GC. // Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc. // This is where content is actually remove from the Yjs Doc.
if (doc.gc) { if (doc.gc) {
for (const [client, deleteItems] of ds.clients) { tryGcDeleteSet(ds, store, doc.gcFilter)
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep) {
struct.gc(store, false)
}
}
}
}
}
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
} }
tryMergeDeleteSet(ds, store)
// on all affected store.clients props, try to merge // on all affected store.clients props, try to merge
for (const [client, clock] of transaction.afterState) { for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0 const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) { if (beforeClock !== clock) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries // we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1) const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) { for (let i = structs.length - 1; i >= firstChangePos; i--) {
@@ -274,10 +304,9 @@ const cleanupTransactions = (transactionCleanups, i) => {
// try to merge mergeStructs // try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left // @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates // but at the moment DS does not handle duplicates
for (const mid of transaction._mergeStructs) { for (let i = 0; i < mergeStructs.length; i++) {
const client = mid.client const { client, clock } = mergeStructs[i].id
const clock = mid.clock const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock) const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) { if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1) tryToMergeWithLeft(structs, replacedStructPos + 1)
@@ -286,6 +315,10 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryToMergeWithLeft(structs, replacedStructPos) tryToMergeWithLeft(structs, replacedStructPos)
} }
} }
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
doc.clientID = generateNewClientId()
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
}
// @todo Merge all the transactions into one and provide send the data as a single update message // @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc]) doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) { if (doc._observers.has('update')) {
@@ -310,7 +343,6 @@ const cleanupTransactions = (transactionCleanups, i) => {
* @param {function(Transaction):void} f * @param {function(Transaction):void} f
* @param {any} [origin=true] * @param {any} [origin=true]
* *
* @private
* @function * @function
*/ */
export const transact = (doc, f, origin = null, local = true) => { export const transact = (doc, f, origin = null, local = true) => {

View File

@@ -3,14 +3,14 @@ import {
iterateDeletedStructs, iterateDeletedStructs,
keepItem, keepItem,
transact, transact,
createID,
redoItem, redoItem,
iterateStructs, iterateStructs,
isParentOf, isParentOf,
createID,
followRedone, followRedone,
getItemCleanStart, getItemCleanStart,
getState, getState,
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as time from 'lib0/time.js' import * as time from 'lib0/time.js'
@@ -19,13 +19,13 @@ import { Observable } from 'lib0/observable.js'
class StackItem { class StackItem {
/** /**
* @param {DeleteSet} ds * @param {DeleteSet} ds
* @param {number} start clock start of the local client * @param {Map<number,number>} beforeState
* @param {number} len * @param {Map<number,number>} afterState
*/ */
constructor (ds, start, len) { constructor (ds, beforeState, afterState) {
this.ds = ds this.ds = ds
this.start = start this.beforeState = beforeState
this.len = len this.afterState = afterState
/** /**
* Use this to save and restore metadata like selection range * Use this to save and restore metadata like selection range
*/ */
@@ -50,27 +50,58 @@ const popStackItem = (undoManager, stack, eventType) => {
transact(doc, transaction => { transact(doc, transaction => {
while (stack.length > 0 && result === null) { while (stack.length > 0 && result === null) {
const store = doc.store const store = doc.store
const clientID = doc.clientID
const stackItem = /** @type {StackItem} */ (stack.pop()) const stackItem = /** @type {StackItem} */ (stack.pop())
const stackStartClock = stackItem.start /**
const stackEndClock = stackItem.start + stackItem.len * @type {Set<Item>}
*/
const itemsToRedo = new Set() const itemsToRedo = new Set()
// @todo iterateStructs should not need the structs parameter /**
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientID)) * @type {Array<Item>}
*/
const itemsToDelete = []
let performedChange = false let performedChange = false
if (stackStartClock !== stackEndClock) { stackItem.afterState.forEach((endClock, client) => {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end) const startClock = stackItem.beforeState.get(client) || 0
getItemCleanStart(transaction, createID(clientID, stackStartClock)) const len = endClock - startClock
if (stackEndClock < getState(doc.store, clientID)) { // @todo iterateStructs should not need the structs parameter
getItemCleanStart(transaction, createID(clientID, stackEndClock)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
if (startClock !== endClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
// this must be executed before deleted structs are iterated.
getItemCleanStart(transaction, createID(client, startClock))
if (endClock < getState(doc.store, client)) {
getItemCleanStart(transaction, createID(client, endClock))
}
iterateStructs(transaction, structs, startClock, len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > len) {
getItemCleanStart(transaction, createID(item.id.client, endClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
} }
} })
iterateDeletedStructs(transaction, stackItem.ds, struct => { iterateDeletedStructs(transaction, stackItem.ds, struct => {
const id = struct.id
const clock = id.clock
const client = id.client
const startClock = stackItem.beforeState.get(client) || 0
const endClock = stackItem.afterState.get(client) || 0
if ( if (
struct instanceof Item && struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) && scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval. // Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(struct.id.client === clientID && struct.id.clock >= stackStartClock && struct.id.clock < stackEndClock) !(clock >= startClock && clock < endClock)
) { ) {
itemsToRedo.add(struct) itemsToRedo.add(struct)
} }
@@ -78,27 +109,6 @@ const popStackItem = (undoManager, stack, eventType) => {
itemsToRedo.forEach(struct => { itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
}) })
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
iterateStructs(transaction, structs, stackStartClock, stackItem.len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > stackItem.len) {
getItemCleanStart(transaction, createID(item.id.client, stackEndClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
// We want to delete in reverse order so that children are deleted before // We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered. // parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) { for (let i = itemsToDelete.length - 1; i >= 0; i--) {
@@ -181,17 +191,17 @@ export class UndoManager extends Observable {
// neither undoing nor redoing: delete redoStack // neither undoing nor redoing: delete redoStack
this.redoStack = [] this.redoStack = []
} }
const beforeState = transaction.beforeState.get(this.doc.clientID) || 0 const beforeState = transaction.beforeState
const afterState = transaction.afterState.get(this.doc.clientID) || 0 const afterState = transaction.afterState
const now = time.getUnixTime() const now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) { if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op // append change to last stack op
const lastOp = stack[stack.length - 1] const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet]) lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.len = afterState - lastOp.start lastOp.afterState = afterState
} else { } else {
// create a new stack op // create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState)) stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
} }
if (!undoing && !redoing) { if (!undoing && !redoing) {
this.lastChange = now this.lastChange = now
@@ -199,13 +209,32 @@ export class UndoManager extends Observable {
// make sure that deleted structs are not gc'd // make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => { iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) { if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item) keepItem(item, true)
} }
}) })
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this]) this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
}) })
} }
clear () {
this.doc.transact(transaction => {
/**
* @param {StackItem} stackItem
*/
const clearItem = stackItem => {
iterateDeletedStructs(transaction, stackItem.ds, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
this.undoStack.forEach(clearItem)
this.redoStack.forEach(clearItem)
})
this.undoStack = []
this.redoStack = []
}
/** /**
* UndoManager merges Undo-StackItem if they are created within time-gap * UndoManager merges Undo-StackItem if they are created within time-gap
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next * smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next

View File

@@ -19,15 +19,15 @@ import {
GCRef, GCRef,
ItemRef, ItemRef,
writeID, writeID,
createID,
readID, readID,
getState, getState,
createID,
getStateVector, getStateVector,
readAndApplyDeleteSet, readAndApplyDeleteSet,
writeDeleteSet, writeDeleteSet,
createDeleteSetFromStructStore, createDeleteSetFromStructStore,
transact, transact,
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line Doc, Transaction, GC, Item, StructStore, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
@@ -36,7 +36,7 @@ import * as binary from 'lib0/binary.js'
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @param {Array<AbstractStruct>} structs All structs by `client` * @param {Array<GC|Item>} structs All structs by `client`
* @param {number} client * @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)` * @param {number} clock write structs starting with `ID(client,clock)`
* *
@@ -50,35 +50,12 @@ const writeStructs = (encoder, structs, client, clock) => {
writeID(encoder, createID(client, clock)) writeID(encoder, createID(client, clock))
const firstStruct = structs[startNewStructs] const firstStruct = structs[startNewStructs]
// write first struct with an offset // write first struct with an offset
firstStruct.write(encoder, clock - firstStruct.id.clock, 0) firstStruct.write(encoder, clock - firstStruct.id.clock)
for (let i = startNewStructs + 1; i < structs.length; i++) { for (let i = startNewStructs + 1; i < structs.length; i++) {
structs[i].write(encoder, 0, 0) structs[i].write(encoder, 0)
} }
} }
/**
* @param {decoding.Decoder} decoder
* @param {number} numOfStructs
* @param {ID} nextID
* @return {Array<GCRef|ItemRef>}
*
* @private
* @function
*/
const readStructRefs = (decoder, numOfStructs, nextID) => {
/**
* @type {Array<GCRef|ItemRef>}
*/
const refs = []
for (let i = 0; i < numOfStructs; i++) {
const info = decoding.readUint8(decoder)
const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, nextID, info) : new ItemRef(decoder, nextID, info)
nextID = createID(nextID.client, nextID.clock + ref.length)
refs.push(ref)
}
return refs
}
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @param {StructStore} store * @param {StructStore} store
@@ -111,22 +88,30 @@ export const writeClientsStructs = (encoder, store, _sm) => {
/** /**
* @param {decoding.Decoder} decoder The decoder object to read data from. * @param {decoding.Decoder} decoder The decoder object to read data from.
* @param {Map<number,Array<GCRef|ItemRef>>} clientRefs
* @return {Map<number,Array<GCRef|ItemRef>>} * @return {Map<number,Array<GCRef|ItemRef>>}
* *
* @private * @private
* @function * @function
*/ */
export const readClientsStructRefs = decoder => { export const readClientsStructRefs = (decoder, clientRefs) => {
/**
* @type {Map<number,Array<GCRef|ItemRef>>}
*/
const clientRefs = new Map()
const numOfStateUpdates = decoding.readVarUint(decoder) const numOfStateUpdates = decoding.readVarUint(decoder)
for (let i = 0; i < numOfStateUpdates; i++) { for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder) const numberOfStructs = decoding.readVarUint(decoder)
const nextID = readID(decoder) const nextID = readID(decoder)
const refs = readStructRefs(decoder, numberOfStructs, nextID) const nextIdClient = nextID.client
clientRefs.set(nextID.client, refs) let nextIdClock = nextID.clock
/**
* @type {Array<GCRef|ItemRef>}
*/
const refs = []
clientRefs.set(nextIdClient, refs)
for (let i = 0; i < numberOfStructs; i++) {
const info = decoding.readUint8(decoder)
const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, createID(nextIdClient, nextIdClock), info) : new ItemRef(decoder, createID(nextIdClient, nextIdClock), info)
refs.push(ref)
nextIdClock += ref.length
}
} }
return clientRefs return clientRefs
} }
@@ -171,16 +156,18 @@ const resumeStructIntegration = (transaction, store) => {
} }
const ref = stack[stack.length - 1] const ref = stack[stack.length - 1]
const m = ref._missing const m = ref._missing
const client = ref.id.client const refID = ref.id
const client = refID.client
const refClock = refID.clock
const localClock = getState(store, client) const localClock = getState(store, client)
const offset = ref.id.clock < localClock ? localClock - ref.id.clock : 0 const offset = refClock < localClock ? localClock - refClock : 0
if (ref.id.clock + offset !== localClock) { if (refClock + offset !== localClock) {
// A previous message from this client is missing // A previous message from this client is missing
// check if there is a pending structRef with a smaller clock and switch them // check if there is a pending structRef with a smaller clock and switch them
const structRefs = clientsStructRefs.get(client) const structRefs = clientsStructRefs.get(client)
if (structRefs !== undefined) { if (structRefs !== undefined) {
const r = structRefs.refs[structRefs.i] const r = structRefs.refs[structRefs.i]
if (r.id.clock < ref.id.clock) { if (r.id.clock < refClock) {
// put ref with smaller clock on stack instead and continue // put ref with smaller clock on stack instead and continue
structRefs.refs[structRefs.i] = ref structRefs.refs[structRefs.i] = ref
stack[stack.length - 1] = r stack[stack.length - 1] = r
@@ -282,7 +269,8 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
* @function * @function
*/ */
export const readStructs = (decoder, transaction, store) => { export const readStructs = (decoder, transaction, store) => {
const clientsStructRefs = readClientsStructRefs(decoder) const clientsStructRefs = new Map()
readClientsStructRefs(decoder, clientsStructRefs)
mergeReadStructsIntoPendingReads(store, clientsStructRefs) mergeReadStructsIntoPendingReads(store, clientsStructRefs)
resumeStructIntegration(transaction, store) resumeStructIntegration(transaction, store)
tryResumePendingDeleteReaders(transaction, store) tryResumePendingDeleteReaders(transaction, store)

View File

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

View File

@@ -5,6 +5,7 @@ import * as text from './y-text.tests.js'
import * as xml from './y-xml.tests.js' import * as xml from './y-xml.tests.js'
import * as encoding from './encoding.tests.js' import * as encoding from './encoding.tests.js'
import * as undoredo from './undo-redo.tests.js' import * as undoredo from './undo-redo.tests.js'
import * as consistency from './consistency.tests.js'
import { runTests } from 'lib0/testing.js' import { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js' import { isBrowser, isNode } from 'lib0/environment.js'
@@ -14,7 +15,7 @@ if (isBrowser) {
log.createVConsole(document.body) log.createVConsole(document.body)
} }
runTests({ runTests({
map, array, text, xml, encoding, undoredo map, array, text, xml, consistency, encoding, undoredo
}).then(success => { }).then(success => {
/* istanbul ignore next */ /* istanbul ignore next */
if (isNode) { if (isNode) {

View File

@@ -330,6 +330,7 @@ export const compareStructStores = (ss1, ss2) => {
s1.constructor !== s2.constructor || s1.constructor !== s2.constructor ||
!Y.compareIDs(s1.id, s2.id) || !Y.compareIDs(s1.id, s2.id) ||
s1.deleted !== s2.deleted || s1.deleted !== s2.deleted ||
// @ts-ignore
s1.length !== s2.length s1.length !== s2.length
) { ) {
t.fail('Structs dont match') t.fail('Structs dont match')

View File

@@ -1,5 +1,8 @@
import * as Y from './testHelper.js' import * as Y from './testHelper.js'
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
const { init, compare } = Y const { init, compare } = Y
/** /**
@@ -158,3 +161,228 @@ export const testToJson = tc => {
text0.insert(0, 'abc', { bold: true }) text0.insert(0, 'abc', { bold: true })
t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text') t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text')
} }
/**
* @param {t.TestCase} tc
*/
export const testToDeltaEmbedAttributes = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, 'ab', { bold: true })
text0.insertEmbed(1, { image: 'imageSrc.png' }, { width: 100 })
const delta0 = text0.toDelta()
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' }, attributes: { width: 100 } }, { insert: 'b', attributes: { bold: true } }])
}
/**
* @param {t.TestCase} tc
*/
export const testToDeltaEmbedNoAttributes = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, 'ab', { bold: true })
text0.insertEmbed(1, { image: 'imageSrc.png' })
const delta0 = text0.toDelta()
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present')
}
/**
* @param {t.TestCase} tc
*/
export const testFormattingRemoved = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, 'ab', { bold: true })
text0.delete(0, 2)
t.assert(Y.getTypeChildren(text0).length === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testFormattingRemovedInMidText = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, '1234')
text0.insert(2, 'ab', { bold: true })
text0.delete(2, 2)
t.assert(Y.getTypeChildren(text0).length === 3)
}
/**
* @param {t.TestCase} tc
*
export const testLargeFragmentedDocument = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
// @ts-ignore
text0.doc.transact(() => {
for (let i = 0; i < 1000000; i++) {
text0.insert(0, '0')
}
})
t.measureTime('time to apply', () => {
testConnector.flushAllMessages()
})
}
*/
// RANDOM TESTS
let charCounter = 0
const marks = [
{ bold: true },
{ italic: true },
{ italic: true, color: '#888' }
]
const marksChoices = [
undefined,
...marks
]
/**
* @type Array<function(any,prng.PRNG):void>
*/
const qChanges = [
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert text
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
const attrs = prng.oneOf(gen, marksChoices)
const text = charCounter++ + prng.word(gen)
ytext.insert(insertPos, text, attrs)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert embed
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // delete text
const ytext = y.getText('text')
const contentLen = ytext.toString().length
const insertPos = prng.int32(gen, 0, contentLen)
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
ytext.delete(insertPos, overwrite)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // format text
const ytext = y.getText('text')
const contentLen = ytext.toString().length
const insertPos = prng.int32(gen, 0, contentLen)
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
const format = prng.oneOf(gen, marks)
ytext.format(insertPos, overwrite, format)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert codeblock
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
const text = charCounter++ + prng.word(gen)
const ops = []
if (insertPos > 0) {
ops.push({ retain: insertPos })
}
ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } })
ytext.applyDelta(ops)
}
]
/**
* @param {any} result
*/
const checkResult = result => {
for (let i = 1; i < result.testObjects.length; i++) {
const p1 = result.users[i].getText('text').toDelta()
const p2 = result.users[i].getText('text').toDelta()
t.compare(p1, p2)
}
// Uncomment this to find formatting-cleanup issues
// const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text'))
// t.assert(cleanups === 0)
return result
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges1 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges2 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges2Repeat = tc => {
for (let i = 0; i < 1000; i++) {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges3 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 3))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges30 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 30))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges40 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 40))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges70 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 70))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges100 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 100))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges300 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 300))
}

View File

@@ -39,10 +39,7 @@
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */ "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": { "paths": {
"yjs": ["./src/index.js"], "yjs": ["./src/index.js"]
"lib0/*": ["node_modules/lib0/*"],
"lib0/set.js": ["node_modules/lib0/set.js"],
"lib0/function.js": ["node_modules/lib0/function.js"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */ // "typeRoots": [], /* List of folders to include type definitions from. */
@@ -60,9 +57,8 @@
/* Experimental Options */ /* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"maxNodeModuleJsDepth": 5, // "maxNodeModuleJsDepth": 0,
// "types": ["./src/utils/typedefs.js"] // "types": ["./src/utils/typedefs.js"]
}, },
"include": ["./src/**/*.js", "./tests/**/*.js"], "include": ["./src/**/*.js", "./tests/**/*.js"]
"exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
} }