Compare commits

...

144 Commits

Author SHA1 Message Date
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
Kevin Jahns
0fb55981ba 13.0.0 2020-01-23 21:53:02 +01:00
Kevin Jahns
89378e29ae publish stable Yjs release 🎆 2020-01-23 21:51:26 +01:00
Kevin Jahns
cce35270ec typescript typingis!!! fixes #180 2020-01-23 21:45:56 +01:00
Kevin Jahns
d78180bf97 make opts optional in PermanentUserData 2020-01-23 18:05:12 +01:00
Kevin Jahns
0ab415de3e 13.0.0-108 2020-01-23 05:01:05 +01:00
Kevin Jahns
ff3969caeb dedupe npm 2020-01-23 05:00:11 +01:00
Kevin Jahns
c82cc9f8d6 lint 2020-01-23 04:59:17 +01:00
Kevin Jahns
ef5c71bd8b PermanentUserData fixes 2020-01-23 04:58:02 +01:00
Kevin Jahns
bd6be3d23b 13.0.0-107 2020-01-22 16:45:48 +01:00
Kevin Jahns
0e6deab9c9 type toJSON returns 2020-01-22 16:44:30 +01:00
Kevin Jahns
6cd9e2be32 lint 2020-01-22 16:42:16 +01:00
Kevin Jahns
ac8dab1e88 Merge pull request #179 from garth/text-tojson
basic Y.Text toJSON returns {unformatted:string}
2020-01-22 16:19:01 +01:00
Garth Williams
38ed725c2c basic Y.Text toJSON returns unformatted string
This avoids text nodes in nested structures returning undefined when toJSON is called by a parent.
2020-01-22 13:34:13 +01:00
Kevin Jahns
a210bad25e update keywords 2020-01-19 00:43:23 +01:00
Kevin Jahns
6929a4f0f8 13.0.0-106 2020-01-14 05:16:43 +01:00
Kevin Jahns
52dacfa5f2 update package-lock 2020-01-14 05:15:36 +01:00
Kevin Jahns
27efe86f9c isParentOf 2020-01-14 05:13:51 +01:00
Kevin Jahns
882b9055c7 fix localimports path ending 2020-01-14 02:36:29 +01:00
Kevin Jahns
e089089413 fix debug resolve 2020-01-13 17:03:56 +01:00
Kevin Jahns
197932752e 13.0.0-105 2020-01-13 14:55:05 +01:00
Kevin Jahns
f0b2bdaf34 revert to classic cjs module 2020-01-13 14:54:07 +01:00
Kevin Jahns
b96362c0f1 use correct module script 2020-01-13 07:55:58 +01:00
Kevin Jahns
67f241cd7a 13.0.0-104 2020-01-13 07:48:47 +01:00
Kevin Jahns
c8af0bebf7 fix preversion script 2020-01-13 07:47:43 +01:00
Kevin Jahns
4f35e799a6 update to lib0@.2 2020-01-13 07:41:31 +01:00
Kevin Jahns
eb2a52dd26 update README with podcast links, consulting info, and y-webrtc 2019-12-11 13:26:46 +01:00
Kevin Jahns
189b1068ae 13.0.0-103 2019-12-10 20:52:20 +01:00
Kevin Jahns
7a3b60a5d7 add markdownlint-cli as dep 2019-12-10 20:51:07 +01:00
Kevin Jahns
99f06fc093 bump lib0 for improved encoding performance 2019-12-10 20:46:58 +01:00
Kevin Jahns
22917bca19 fix gc & proper options typings for Y.Doc, fixes #176 2019-12-10 17:51:49 +01:00
Kevin Jahns
7f0e25dcba permanent user store writes updates in separate transaction 2019-12-10 17:18:57 +01:00
Kevin Jahns
d90c9b1cb2 bump lib0 for faster text encoding 2019-12-10 00:26:28 +01:00
Kevin Jahns
c426055f17 spelling 2019-12-10 00:19:02 +01:00
Kevin Jahns
18c9010b63 Merge branch 'master' of github.com:y-js/yjs 2019-11-26 13:02:49 +01:00
Kevin Jahns
c3edac62ef doc typo 2019-11-26 13:02:43 +01:00
Kevin Jahns
755de18fd5 Create Funding.yml 2019-11-07 14:41:50 +01:00
Kevin Jahns
641dc25076 13.0.0-102 2019-10-25 23:47:23 +02:00
Kevin Jahns
1d58ea785f Merge branch 'master' of github.com:yjs/yjs 2019-10-25 23:45:50 +02:00
Kevin Jahns
f53dff5043 delay errors in observe callbacks to throw after cleanup is done 2019-10-25 23:44:09 +02:00
Kevin Jahns
74d1a31f49 Merge pull request #174 from boschDev/master
Fix attrs loop in yXmlText
2019-10-15 17:19:30 +02:00
Roeland Bosch
d1063ab70b Fix attrs loop in yXmlText 2019-10-15 17:07:20 +02:00
Kevin Jahns
f4c919d9ec 13.0.0-101 2019-10-08 18:33:50 +02:00
Kevin Jahns
aeb23dbaa9 follow redone items to prevent some undo-redo issues. Fixes #162 2019-10-08 18:31:56 +02:00
Kevin Jahns
6d4f0c0cdd 13.0.0-100 2019-10-08 17:40:32 +02:00
Kevin Jahns
303138f309 sanitize items before undoing. fixes #165 2019-10-08 17:36:00 +02:00
Kevin Jahns
ad373a3dce Merge pull request #172 from istvank/patch-1
Fixing Y.Map's documentation of forEach
2019-10-05 20:09:53 +02:00
István Koren
2150fa58f2 Fixing Y.Map's documentation of forEach
fixes #171 As always, it's an honor to submit a PR! 🐒 There was also a missing dot in the Y.XmlFragment title.
2019-10-05 15:14:30 +02:00
Kevin Jahns
ece4841b5c update stackItem.meta doc 2019-10-03 22:06:07 +02:00
Kevin Jahns
8103220c05 Merge branch 'master' of github.com:yjs/yjs 2019-09-30 11:10:13 +02:00
Kevin Jahns
66d500f08d YEvent: consider case that item was added & removed in the same transaction 2019-09-30 11:10:03 +02:00
Kevin Jahns
5f8e7c7ba7 Merge pull request #169 from yjs/improve-readme
update quill cursors support
2019-09-23 11:22:51 +02:00
Nik Graf
7b8eee6b25 update quill cursors support 2019-09-23 11:22:24 +02:00
Kevin Jahns
1d5947c602 13.0.0-99 2019-09-23 11:11:45 +02:00
Kevin Jahns
53e4028952 Merge pull request #168 from yjs/fix-absolute-position-calculation
fix absolute position calculation
2019-09-23 11:09:48 +02:00
Nik Graf
b38a8d99e5 fix absolute position calculation 2019-09-23 11:05:50 +02:00
Kevin Jahns
6c4971ae25 13.0.0-98 2019-09-17 18:55:04 +02:00
Kevin Jahns
d1f5ff0f59 implement PermanentUserData storage prototype 2019-09-17 18:53:59 +02:00
Kevin Jahns
1d297601e8 export .createDeleteSet functionality 2019-09-04 22:08:05 +02:00
Kevin Jahns
d9fface0be 13.0.0-97 2019-09-04 13:21:10 +02:00
Kevin Jahns
7d5db917da fix type error >= tsc@3.6 2019-09-04 13:19:25 +02:00
Kevin Jahns
6e7529723d update lib0 2019-09-04 13:15:34 +02:00
Kevin Jahns
6cb64b3707 move repository to yjs org 2019-09-04 13:08:34 +02:00
Kevin Jahns
bb1c0b809f implement snapshot & event.changes 2019-09-03 16:33:29 +02:00
Kevin Jahns
8bcff6138c Y.Text snapshot support (toDelta) 2019-08-31 22:42:18 +02:00
Kevin Jahns
e78d84ee59 md lint 2019-08-31 16:47:12 +02:00
Kevin Jahns
c23bcb66ce delta format: use flat attr comparison 2019-08-31 16:44:07 +02:00
Kevin Jahns
5fddcef3ea Update logo 2019-08-29 12:51:16 +02:00
Kevin Jahns
e1e46c6eb1 Merge branch 'master' of github.com:y-js/yjs 2019-08-27 02:17:16 +02:00
Kevin Jahns
13ad0c8464 implement Y.XmlFragment.length 2019-08-27 02:17:08 +02:00
Kevin Jahns
7700b50470 Merge pull request #161 from blackening/master
Updated documentation for Y.Array forEach
2019-08-20 23:18:46 +02:00
Kevin Jahns
fc4d6165b4 13.0.0-96 2019-08-20 22:29:56 +02:00
Kevin Jahns
251c8aaefc UndoManager configuration to filter deletes 2019-08-20 22:28:49 +02:00
Kevin Jahns
1337d38ada 13.0.0-95 2019-08-09 01:18:15 +02:00
Kevin Jahns
f5c66e41cb audit 2019-08-09 01:16:40 +02:00
Kevin Jahns
0e7da017fe Use lib0/any-encoding instead of JSON 2019-08-09 01:15:46 +02:00
blackening
f0262ffaae Updated documentation for Y.Array forEach
Reference:
https://github.com/y-js/yjs/blob/master/src/types/YArray.js#L186
https://github.com/y-js/yjs/blob/master/src/types/AbstractType.js#L239
2019-07-09 19:58:06 +08:00
Kevin Jahns
36203af88e should not rely on all deconstructing features because not all parsers support it 2019-06-29 14:47:34 +02:00
Kevin Jahns
dd2b8bc6c7 13.0.0-94 2019-06-25 11:57:50 +02:00
Kevin Jahns
463065ac21 UndoManager: keep item before item is deleted (fixes some edge cases of followRedo) 2019-06-25 11:56:41 +02:00
Kevin Jahns
d064e6e96e UndoManager accepts an array of types as scope. Implements #156 2019-06-25 02:26:18 +02:00
Kevin Jahns
b1ed2df208 proper TOC links 2019-06-25 00:10:12 +02:00
Kevin Jahns
1fe4ef135c 13.0.0-93 2019-06-24 23:06:11 +02:00
Kevin Jahns
e376b5d472 UndoManager fixes 2019-06-24 23:04:53 +02:00
Kevin Jahns
952a9b2c41 13.0.0-92 2019-06-23 13:05:30 +02:00
Kevin Jahns
03458dc641 Port Undo/Redo approach with a clean API 2019-06-23 13:04:14 +02:00
Kevin Jahns
14df5b72af fix consistency bug - ref.toStruct does not correctly create GC when offset is specified 2019-06-18 18:46:19 +02:00
Kevin Jahns
338968031b 13.0.0-91 2019-06-18 18:05:39 +02:00
Kevin Jahns
1aac245b93 New types dont fire events - fixes #155 2019-06-18 17:41:19 +02:00
Kevin Jahns
1faff323c1 13.0.0-90 2019-06-14 16:00:02 +02:00
Kevin Jahns
e7280c7ae2 allow case sensitive yxml nodes 2019-06-14 15:59:00 +02:00
Kevin Jahns
4c38619b5d 13.0.0-89 2019-06-13 10:33:35 +02:00
Kevin Jahns
b4e5c5cc1f Correctly insert embed when using YText.applyDelta 2019-06-13 10:30:39 +02:00
Kevin Jahns
b0dbd84f7f lint markdown 2019-06-13 10:28:30 +02:00
Kevin Jahns
4a990963d9 13.0.0-88 2019-06-05 18:37:21 +02:00
Kevin Jahns
7e7c9d5b11 add relevant type information 2019-06-05 14:53:00 +02:00
Kevin Jahns
775f6eed1d fix websocket example 2019-06-02 15:16:14 +02:00
Kevin Jahns
1e83b9418c 13.0.0-87 2019-05-28 14:20:44 +02:00
Kevin Jahns
ac3f672c80 Merge branch 'master' of github.com:y-js/yjs 2019-05-28 14:19:11 +02:00
Kevin Jahns
2192aa5821 Use generic Item with typed content to reduce cache misses 2019-05-28 14:18:20 +02:00
Kevin Jahns
70bb523005 Merge branch 'master' of github.com:y-js/yjs 2019-05-27 12:50:21 +02:00
Kevin Jahns
10ce6de57a import statement fix 2019-05-27 12:50:12 +02:00
Kevin Jahns
3fba4f25a5 Merge pull request #153 from calibr/124-text-embeds
process embeds in YText.toDelta
2019-05-25 13:04:10 +02:00
Kevin Jahns
66c35d8499 testing: do not stringify array values before comparing 2019-05-25 12:54:30 +02:00
Kevin Jahns
4c14157dcf 13.0.0-86 2019-05-25 12:50:05 +02:00
Kevin Jahns
ef6c382e20 fix array iterator on merged content. fixes #152 2019-05-25 12:49:08 +02:00
calibr
ee45b4fdd6 process embeds in YText.toDelta 2019-05-25 13:48:57 +03:00
Kevin Jahns
668e9e8a9b 13.0.0-85 2019-05-25 03:13:54 +02:00
Kevin Jahns
37a6d68543 implement support for boolean values. fixes #151 2019-05-25 03:12:56 +02:00
Kevin Jahns
f893198769 remove examples. fixes #149 2019-05-22 17:32:51 +02:00
Kevin Jahns
d3ee1a0ec2 Add editor support to v13 readme 2019-05-22 01:26:13 +02:00
Kevin Jahns
d6593412a2 13.0.0-84 2019-05-19 21:49:36 +02:00
Kevin Jahns
d31bf36531 use generated esm module by default 2019-05-19 21:48:09 +02:00
Kevin Jahns
a485f550db 13.0.0-83 2019-05-19 20:59:56 +02:00
Kevin Jahns
0610b16227 bump lib0 for webpack compatibility 2019-05-19 20:43:18 +02:00
Kevin Jahns
72e470c5f0 Fix ytext event.delta - items that are synced and deleted
When items are added and deleted in the same transaction, event.delta would recognize them as added (though they are actually deleted). Now it just ignores them.
2019-05-19 20:42:53 +02:00
Kevin Jahns
4d12a02e2f fix offset in state vector 2019-05-16 12:31:53 +02:00
Kevin Jahns
4a7d6f0a2d fix sorting bug that only affects older node versions (probably because old sorting algorithms are not stable) 2019-05-14 15:21:34 +02:00
Kevin Jahns
c80f446b5f README: update provider tutorial 2019-05-12 11:18:43 +02:00
Kevin Jahns
81a529d8dc update *getting started* yjs version 2019-05-07 15:43:09 +02:00
Kevin Jahns
4f0ab78914 13.0.0-82 2019-05-07 13:54:00 +02:00
Kevin Jahns
8c36f67f0b rework and document api 2019-05-07 13:44:23 +02:00
Kevin Jahns
77687d94e6 13.0.0-81 2019-04-28 17:32:05 +02:00
Kevin Jahns
4644511303 bump y-protocols dependency 2019-04-28 17:30:52 +02:00
Kevin Jahns
20005eecdb Merge deleted items more efficiently.
Previously deleted items were simply added to transaction._mergeStructs. But this inherently inefficient as it will splice the struct store for every item.

Now Yjs iterates over transaction.ds and tries to merge structs. It iterates from right to left so merging should be more efficient that before. But more work needs to be done.

For example we could set structs[i] = null and filter the structs after merging is done.
2019-04-28 17:20:35 +02:00
Kevin Jahns
c9dda245bf v13 api docs 2019-04-28 02:53:25 +02:00
Kevin Jahns
1417470156 update demos link 2019-04-27 03:44:48 +02:00
Kevin Jahns
584e5dfd40 Link to v13 docs from README 2019-04-27 03:35:44 +02:00
Kevin Jahns
805acbb9f5 13.0.0-80 2019-04-26 19:55:14 +02:00
Kevin Jahns
32c4c09072 update parent._map when splitting an item 2019-04-26 19:54:00 +02:00
Kevin Jahns
8c5a06bbf8 fix gc when item is deleted in observer call 2019-04-26 18:37:38 +02:00
Kevin Jahns
a336cc167c order observer and transaction cleanups after one another 2019-04-26 13:31:00 +02:00
Kevin Jahns
21d86cd2be Delete all children of ItemType when it is deleted 2019-04-26 12:29:28 +02:00
Kevin Jahns
1d0f9faa91 AbstractItem.mergeWith helper outsourced into separate function 2019-04-24 18:10:33 +02:00
Kevin Jahns
45237571b7 gc more efficiently 2019-04-23 20:51:32 +02:00
Kevin Jahns
bb6f6cd141 13.0.0-79 2019-04-20 00:03:30 +02:00
Kevin Jahns
729c1f16b8 fix test provider 2019-04-20 00:02:40 +02:00
Kevin Jahns
b6059704aa update dependencies 2019-04-20 00:00:09 +02:00
75 changed files with 6865 additions and 10168 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# 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']

View File

@@ -17,7 +17,7 @@
"useCollapsibles": true, "useCollapsibles": true,
"collapse": true, "collapse": true,
"resources": { "resources": {
"y-js.org": "yjs.website" "yjs.dev": "Yjs website"
}, },
"logo": { "logo": {
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png", "url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",

4
.markdownlint.json Normal file
View File

@@ -0,0 +1,4 @@
{
"default": true,
"no-inline-html": false
}

1112
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,370 +0,0 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
> The shared editing library
Yjs is a library for automatic conflict resolution on shared state. It implements an operation-based CRDT and exposes its internal CRDT model as shared types. Shared types are common data types like `Map` or `Array` with superpowers - changes are automatically distributed to other peers and merged without merge conflicts.
Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**, **offline editing**, **version snapshots**, **shared cursors**, and encodes update messages using **binary protocol encoding**.
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
* Demos: [https://yjs.website/tutorial-prosemirror.html](https://yjs.website/tutorial-prosemirror.html)
* API Docs: [https://yjs.website/](https://yjs.website/)
### Supported Editors:
| Name                                                   | Cursors | Binding | Demo |
|---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [link](https://yjs.website/tutorial-prosemirror.html) |
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [link](https://yjs.website/tutorial-quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [link](https://yjs.website/tutorial-codemirror.html) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [link]() |
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [y-monaco](http://github.com/y-js/y-monaco) | [link]() |
### Providers
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. *Providers* manage all that for you and are a good off-the-shelf solution
* [y-websockets](http://github.com/y-js/y-websockets)
* [y-webrtc](http://github.com/y-js/y-webrtc)
* [y-dat](http://github.com/y-js/y-dat)
### Shared Types
# Table of Contents
* [Getting Started](#Getting-Started)
* [Tutorial](#Short-Tutorial)
* [Providers](#Providers)
* [Websocket](#Websocket)
* [Ydb](#Ydb)
* [Create a Custom Provider](#Create-a-Custom-Provider)
* [Shared Types](#Shared-Types)
* [YArray](#Yarray)
* [YMap](#YMap)
* [YText](#YText)
* [YXmlFragment and YXmlElement](#YXmlFragment-and-YXmlElement)
* [Create a Custom Type](#Create-a-Custom-Type)
* [Bindings](#Bindings)
* [PromeMirror](#ProseMirror)
* [Quill](#Quill)
* [CodeMirror](#CodeMirror)
* [Ace](#Ace)
* [Monaco](#Monace)
* [DOM](#DOM)
* [Textarea](#Textarea)
* [Create a Custom Binding](#Create-a-Custom-Binding)
* [Transaction](#Transaction)
* [Offline Editing](#Offline-Editing)
* [Awareness](#Awareness)
* [Working with Yjs](#Working-with-Yjs)
* [Typescript Declarations](#Typescript-Declarations)
* [Binary Protocols](#Binary-Protocols)
* [Sync Protocol](#Sync-Protocols)
* [Awareness Protocol](#Awareness-Protocols)
* [Auth Protocol](#Auth-Protocol)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
* [Evaluation](#Evaluation)
* [Existing shared editing libraries](#Exisisting-Javascript-Products)
* [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)
## Getting Started
Yjs does not hava any dependencies. Install this package with your favorite package manager, or just copy the files into your project.
```sh
npm i yjs
```
##### Quickstart
Yjs itself only knows how to do conflict resolution. You need to choose a provider, that handles how document updates are distributed over the network.
We will start by running a websocket server (part of the [websocket provider](#Websocket-Provider)):
```sh
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
```
The following client-side code connects to the websocket server and opens a shared document.
```js
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
const provider = new WebsocketProvider('http://localhost:1234')
const sharedDocument = provider.get('my-favourites')
```
All content created in a shared document is shared among all peers that request the same document. Now we define types on the shared document:
```js
sharedDocument.define('movie-ratings', Y.Map)
sharedDocument.define('favourite-food', Y.Array)
```
All clients that define `'movie-ratings'` as `Y.Map` on the shared document named `'my-favourites'` have access to the same shared type. Example:
**Client 1:**
```js
sharedDocument.define('movie-ratings', Y.Map)
sharedDocument.define('favourite-food', Y.Array)
const movies = sharedDocument.get('movie-ratings')
const food = sharedDocument.get('fovourite-food')
movies.set('deadpool', 10)
food.insert(0, ['burger'])
```
**Client 2:**
```js
sharedDocument.define('movie-ratings', Y.Map)
sharedDocument.define('favourite-food', Y.Map) // <- note that this definition differs from client1
const movies = sharedDocument.get('movie-ratings')
const food = sharedDocument.get('fovourite-food')
movies.set('yt rewind', -10)
food.set('pancake', 10)
// after some time, when client1 and client2 synced, the movie list will be merged:
movies.toJSON() // => { 'deadpool': 10, 'yt rewind': -10 }
// But since client1 and client2 defined the types differently,
// they do not have access to each others food list.
food.toJSON() // => { pancake: 10 }
```
Now you understand how types are defined on a shared document. Next you can jump to one of the [tutorials on our website](https://yjs.website/tutorial-prosemirror.html) or continue reading about [Providers](#Providers), [Shared Types](#Shared-Types), and [Bindings](#Bindings).
## API
## Providers
In Yjs, a provider handles the communication channel to *authenticate*, *authorize*, and *exchange document updates*. Yjs ships with some existing providers.
### Websocket Provider
The websocket provider implements a classical client server model. Clients connect to a single endpoint over websocket. The server distributes awareness information and document updates among clients.
The Websocket Provider is a solid choice if you want a central source that handles authentication and authorization. Websockets also send header information and cookies, so you can use existing authentication mechanisms with this server. I recommend that you slightly adapt the server in `./provider/websocket/server.js` to your needs.
* Supports cross-tab communication. When you open the same document in the same browser, changes on the document are exchanged via cross-tab communication ([Broadcast Channel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) as fallback).
* Supports exange of awareness information (e.g. cursors)
##### Start a Websocket Server:
```sh
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
```
**Websocket Server with Persistence**
Persist document updates in a LevelDB database.
See [LevelDB Persistence](#LevelDB Persistence) for more info.
```sh
PORT=1234 YPERSISTENCE=./dbDir node ./node_modules/yjs/provider/websocket/server.js
```
##### Client Code:
```js
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
const provider = new WebsocketProvider('http://localhost:1234')
// open a websocket connection to http://localhost:1234/my-document-name
const sharedDocument = provider.get('my-document-name')
sharedDocument.on('status', event => {
console.log(event.status) // logs "connected" or "disconnected"
})
```
#### Scaling
These are mere suggestions how you could scale your server environment.
**Option 1:** Websocket servers communicate with each other via a PubSub server. A room is represented by a PubSub channel. The downside of this approach is that the same shared document may be handled by many servers. But the upside is that this approach is fault tolerant, does not have a single point of failure, and is perfectly fit for route balancing.
**Option 2:** Sharding with *consistent hashing*. Each document is handled by a unique server. This patterns requires an entity, like etcd, that performs regular health checks and manages servers. Based on the list of available servers (which is managed by etcd) a proxy calculates which server is responsible for each requested document. The disadvantage of this approach is that it is that load distribution may not be fair. Still, this approach may be the preferred solution if you want to store the shared document in a database - e.g. for indexing.
### Ydb Provider
TODO
### Create Custom Provider
A provider is only a concept. I encourage you to implement the same provider interface found above. This makes it easy to exchange communication protocols.
Since providers handle the communication channel, they will necessarily interact with the [binary protocols](#Binary-Protocols). I suggest that you build upon the existing protocols. But you may also implement a custom communication protocol.
Read section [Sync Protocol](#Sync-Protocol) to learn how syncing works.
## Shared Types
A shared type is just a normal data type like [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). But a shared type may also be modified by a remote client. Conflicts are automatically resolved by the rules described in this section - but please note that this is only a rough overview of how conflict resolution works. Please read the [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) section for an in-depth description of the conflict resolution approach.
As explained in [Tutorial](#Tutorial), a shared type is shared among all peers when they are defined with the same name on the same shared document. I.e.
```js
sharedDocument.define('my-array', Y.Array)
const myArray = sharedDocument.get('my-array')
```
You may define a shared types several times, as long as you don't change the type definition.
```js
sharedDocument.define('my-array', Y.Array)
const myArray = sharedDocument.get('my-array')
const alsoMyArray = sharedDocument.define('my-array', Y.Array)
console.log(myArray === alsoMyArray) // => true
```
All shared types have an `type.observe(event => ..)` method that allows you to observe any changes. You may also observe all changes on a type and any of its children with the `type.observeDeep(events => ..)` method. Here, `events` is the [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) of events that were fired on type, or any of its children.
All Events inherit from [YEvent](https://yjs.website/module-utils.YEvent.html).
### YMap
> Complete API docs: [https://yjs.website/module-types.ymap](https://yjs.website/module-types.ymap)
The YMap type is very similar to the JavaScript [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map).
YMap fires [YMapEvents](https://yjs.website/module-types.YMapEvent.html).
```js
import * as Y from 'yjs'
const ymap = new Y.Map()
ymap.observe(event => {
console.log('ymap keys changed:', event.keysChanged, event.remote)
})
ymap.set('key', 'value') // => ymap keys changed: Set{ 'key' } false
ymap.delete('key') // => ymap keys changed: Set{ 'key' }
const ymap2 = new YMap()
ymap2.set(1, 'two')
ymap.set('type', ymap2) // => ymap keys changed: Set{ 'type' }
```
##### Concurrent YMap changes
* Concurrent edits on different keys do not affect each other. E.g. if client1 does `ymap.set('a', 1)` and client2 does `ymap.set('b', 2)`, both clients will end up with `YMap{ a: 1, b: 2 }`
* If client1 and client2 `set` the same property at the same time, the edit from the client with the smaller userID will prevail (`sharedDocument.userID`)
* If client1 sets a property `ymap.set('a', 1)` and client2 deletes a property `ymap.delete('a')`, the set operation always prevails.
### YArray
> Complete API docs: [https://yjs.website/module-types.yarray](https://yjs.website/module-types.yarray)
YArray fires [YArrayEvents](https://yjs.website/module-types.YMapEvent.html).
```js
import * as Y from 'yjs'
const yarray = new Y.Array()
yarray.observe(event => {
console.log('yarray changed:', event.addedElements, event.removedElements, event.remote)
})
// insert two elements at position 0
yarray.insert(0, ['a', 1]) // => yarray changed: Set{Item{'a'}, Item{1}}, Set{}, false
console.log(yarray.toArray()) // => ['a', 1]
yarray.delete(1, 1) // yarray changed: Set{}, Set{Item{1}}, false
yarray.insert(1, new Y.Map()) // => yarray changed: Set{YMap{}}, Set{}, false
// The difference between .toArray and .toJSON:
console.log(yarray.toArray()) // => ['a', YMap{}]
console.log(yarray.toJSON()) // => ['a', {}]
```
As you can see from the above example, primitive data is wrapped into an Item. This makes it possible to find the exact location of the change.
##### Concurrent YArray changes
* YArray internally represents the data as a doubly linked list. The Array `['a', YMap{}, 1]` is internally represented as `Item{'a'} <-> YMap{} <-> Item{1}`. Accordingly, the insert operation `yarray.insert(1, ['b'])` is internally transformed to `insert Item{'b'} between Item{'a'} and YMap{}`.
* When an Item is deleted, it is only marked as deleted. Only its content is garbage collected and freed from memory.
* Therefore, the remote operation `insert x between a and b` can still be fulfilled when item `a` or item `b` are deleted.
* In case that two clients insert content between the same items (a concurrent insertion), the order of the insertions is decided based on the `sharedDocument.userID`.
### YText
> Complete API docs: [https://yjs.website/module-types.ytext](https://yjs.website/module-types.ytext)
A YText is basically a [YArray](#YArray) that is optimized for text content.
### YXmlFragment and YXmlElement
> Complete API docs: [https://yjs.website/module-types.yxmlfragment](https://yjs.website/module-types.yxmlfragment) and [https://yjs.website/module-types.yxmlelement](https://yjs.website/module-types.yxmlelement)
### Custom Types
## Bindings
## Transaction
## Binary Protocols
### Sync Protocol
Sync steps
### Awareness Protocol
### Auth Protocol
## Offline Editing
It is trivial with Yjs to persist the local state to indexeddb, so it is always available when working offline. But there are two non-trivial questions that need to answered when implementing a professional offline editing app:
1. How does a client sync down all rooms that were modified while offline?
2. How does a client sync up all rooms that were modified while offline?
Assuming 5000 documents are stored on each client for offline usage. How do we sync up/down each of those documents after a client comes online? It would be inefficient to sync each of those rooms separately. The only provider that currently supports syncing many rooms efficiently is Ydb, because its database layer is optimized to sync many rooms with each client.
If you do not care about 1. and 2. you can use `/persistences/indexeddb.js` to mirror the local state to indexeddb.
## Working with Yjs
### Typescript Declarations
Until [this](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, the only way to get type declarations is by adding Yjs to the list of checked files:
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
..
},
"maxNodeModuleJsDepth": 5
}
```
## CRDT Algorithm
## License and Author
Yjs and all related projects are [**MIT licensed**](./LICENSE). Some files also contain an additional copyright notice that allows you to copy and modify the code without shipping the copyright notice (e.g. `./provider/websocket/WebsocketProvider.js` and `./provider/websocket/server.js`)
Yjs is based on the research I did as a student at the RWTH i5. I am working on Yjs in my spare time. Please help me by donating or hiring me for consulting, so I can continue to work on this project.
kevin.jahns@protonmail.com

1
examples/.gitignore vendored
View File

@@ -1 +0,0 @@
build

View File

@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs CodeMirror Example</title>
<link rel=stylesheet href="https://codemirror.net/lib/codemirror.css">
<style>
#container {
border: grey;
border-style: solid;
border-width: thin;
}
</style>
</head>
<body>
<p>This example shows how to bind a YText type to <a href="https://codemirror.net/">CodeMirror</a> editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<div class="code-html">
<style>
.remote-caret {
position: absolute;
border-left: black;
border-left-style: solid;
border-left-width: 2px;
height: 1em;
}
.remote-caret > div {
position: relative;
top: -1.05em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-family: serif;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding-left: 2px;
padding-right: 2px;
}
</style>
<div id="container"></div>
</div>
<!-- The actual source file for the following code is found in ./codemirror.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/codemirror.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { CodeMirrorBinding } from 'yjs/bindings/codemirror.js'
import * as conf from './exampleConfig.js'
import CodeMirror from 'codemirror'
import 'codemirror/mode/javascript/javascript.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('codemirror')
const ytext = ydocument.define('codemirror', Y.Text)
const editor = new CodeMirror(document.querySelector('#container'), {
mode: 'javascript',
lineNumbers: true
})
const binding = new CodeMirrorBinding(ytext, editor)
window.codemirrorExample = {
binding, editor, ytext, ydocument
}
</script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
import { WebsocketProvider } from 'y-websocket'
import { CodeMirrorBinding } from 'y-codemirror'
import * as conf from './exampleConfig.js'
import CodeMirror from 'codemirror'
import 'codemirror/mode/javascript/javascript.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('codemirror')
const ytext = ydocument.getText('codemirror')
const editor = new CodeMirror(document.querySelector('#container'), {
mode: 'javascript',
lineNumbers: true
})
const binding = new CodeMirrorBinding(ytext, editor)
window.codemirrorExample = {
binding, editor, ytext, ydocument
}

View File

@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Prosemirror Example</title>
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
<style>
#content {
min-height: 500px;
}
</style>
</head>
<body>
<p>This example shows how to bind a YXmlFragment type to an arbitrary DOM element. We set the DOM element to contenteditable so it basically behaves like a very powerful rich-text editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<hr>
<div class="code-html">
<div id="content" contenteditable=""></div>
</div>
<!-- The actual source file for the following code is found in ./dom.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/dom.js" type="module">
import * as Y from 'yjs/index.js'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { DomBinding } from 'yjs/bindings/dom.js'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('dom')
const type = ydocument.define('xml', Y.XmlFragment)
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
window.example = {
provider, ydocument, type, binding
}
</script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
import * as Y from '../src/index.js'
import { WebsocketProvider } from 'y-websocket'
import { DomBinding } from 'y-dom'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('dom')
const type = ydocument.define('xml', Y.XmlFragment)
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
window.example = {
provider, ydocument, type, binding
}

View File

@@ -1,9 +0,0 @@
/* eslint-env browser */
const isDeployed = location.hostname === 'yjs.website'
if (!isDeployed) {
console.log('%cYjs: Start your local websocket server by running %c`npm run websocket-server`', 'color:blue', 'color: grey; font-weight: bold')
}
export const serverAddress = isDeployed ? 'wss://api.yjs.website' : 'ws://localhost:1234'

View File

@@ -1,17 +0,0 @@
{
"codemirror": {
"title": "CodeMirror Binding"
},
"prosemirror": {
"title": "ProseMirror Binding"
},
"textarea": {
"title": "Textarea Binding"
},
"quill": {
"title": "Quill Binding"
},
"dom": {
"title": "Dom Binding"
}
}

View File

@@ -1,159 +0,0 @@
import { Plugin } from 'prosemirror-state'
import crel from 'crel'
import * as Y from '../src/index.js'
import { prosemirrorPluginKey } from 'y-prosemirror'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as historyProtocol from 'y-protocols/history.js'
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
[ychange_state][ychange_user="${userid}"]:hover::before {
content: "${username}" !important;
background-color: ${color} !important;
}
[ychange_state="added"][ychange_user="${userid}"] {
background-color: ${color2} !important;
}
[ychange_state="removed"][ychange_user="${userid}"] {
color: ${color} !important;
}
`
export const noteHistoryPlugin = new Plugin({
state: {
init (initargs, state) {
return new NoteHistoryPlugin()
},
apply (tr, pluginState) {
return pluginState
}
},
view (editorView) {
const hstate = noteHistoryPlugin.getState(editorView.state)
hstate.init(editorView)
return {
destroy: hstate.destroy.bind(hstate)
}
}
})
const createWrapper = () => {
const wrapper = crel('div', { style: 'display: flex;' })
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
wrapper.insertBefore(historyContainer, null)
const userStyleContainer = crel('style')
wrapper.insertBefore(userStyleContainer, null)
return { wrapper, historyContainer, userStyleContainer }
}
class NoteHistoryPlugin {
init (editorView) {
this.editorView = editorView
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
this.userStyleContainer = userStyleContainer
this.wrapper = wrapper
this.historyContainer = historyContainer
const n = editorView.dom.parentNode.parentNode
n.parentNode.replaceChild(this.wrapper, n)
n.style['flex-grow'] = '1'
wrapper.insertBefore(n, this.wrapper.firstChild)
this.render()
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
history.observe(this.render.bind(this))
}
destroy () {
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
history.unobserve(this.render)
}
render () {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array).toArray()
const fragment = document.createDocumentFragment()
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
fragment.insertBefore(snapshotBtn, null)
let _prevSnap = null // empty
snapshotBtn.addEventListener('click', () => {
const awareness = y.getAwarenessInfo()
const userMap = new Map()
const aw = y.getLocalAwarenessInfo()
userMap.set(y.userID, aw.name || 'unknown')
awareness.forEach((a, userID) => {
userMap.set(userID, a.name || 'Unknown')
})
this.snapshot(userMap)
})
history.forEach(buf => {
const decoder = decoding.createDecoder(buf)
const snapshot = historyProtocol.readHistorySnapshot(decoder)
const date = new Date(decoding.readUint32(decoder) * 1000)
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
const a = crel('a', [
'• ' + date.toUTCString(), restoreBtn
])
const el = crel('div', [ a ])
let prevSnapshot = _prevSnap // rebind to new variable
restoreBtn.addEventListener('click', event => {
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
event.stopPropagation()
})
a.addEventListener('click', () => {
console.log('setting snapshot')
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.renderSnapshot(snapshot, prevSnapshot)
})
fragment.insertBefore(el, null)
_prevSnap = snapshot
})
this.historyContainer.innerHTML = ''
this.historyContainer.insertBefore(fragment, null)
}
renderSnapshot (snapshot, prevSnapshot) {
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
/**
* @type {Array<string|null>}
*/
let colors = niceColors.slice()
let style = ''
snapshot.userMap.forEach((name, userid) => {
/**
* @type {any}
*/
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
let color = null
let i = 0
for (; i < colors.length && color === null; i++) {
color = colors[(randInt + i) % colors.length]
}
if (color === null) {
colors = niceColors.slice()
i = 0
color = colors[randInt % colors.length]
}
colors[randInt % colors.length] = null
style += createUserCSS(userid, name, color, color + '69')
})
this.userStyleContainer.innerHTML = style
}
/**
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
*/
snapshot (updatedUserMap = new Map()) {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
const encoder = encoding.createEncoder()
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
history.push([encoding.toBuffer(encoder)])
}
}

View File

@@ -1,197 +0,0 @@
import { Schema } from 'prosemirror-model'
const brDOM = ['br']
const calcYchangeDomAttrs = (attrs, domAttrs = {}) => {
domAttrs = Object.assign({}, domAttrs)
if (attrs.ychange !== null) {
domAttrs.ychange_user = attrs.ychange.user
domAttrs.ychange_state = attrs.ychange.state
}
return domAttrs
}
// :: Object
// [Specs](#model.NodeSpec) for the nodes defined in this schema.
export const nodes = {
// :: NodeSpec The top level document node.
doc: {
content: 'block+'
},
// :: NodeSpec A plain paragraph textblock. Represented in the DOM
// as a `<p>` element.
paragraph: {
attrs: { ychange: { default: null } },
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks.
blockquote: {
attrs: { ychange: { default: null } },
content: 'block+',
group: 'block',
defining: true,
parseDOM: [{ tag: 'blockquote' }],
toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A horizontal rule (`<hr>`).
horizontal_rule: {
attrs: { ychange: { default: null } },
group: 'block',
parseDOM: [{ tag: 'hr' }],
toDOM (node) {
return ['hr', calcYchangeDomAttrs(node.attrs)]
}
},
// :: NodeSpec A heading textblock, with a `level` attribute that
// should hold the number 1 to 6. Parsed and serialized as `<h1>` to
// `<h6>` elements.
heading: {
attrs: {
level: { default: 1 },
ychange: { default: null }
},
content: 'inline*',
group: 'block',
defining: true,
parseDOM: [{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
{ tag: 'h3', attrs: { level: 3 } },
{ tag: 'h4', attrs: { level: 4 } },
{ tag: 'h5', attrs: { level: 5 } },
{ tag: 'h6', attrs: { level: 6 } }],
toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A code listing. Disallows marks or non-text inline
// nodes by default. Represented as a `<pre>` element with a
// `<code>` element inside of it.
code_block: {
attrs: { ychange: { default: null } },
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] }
},
// :: NodeSpec The text node.
text: {
group: 'inline'
},
// :: NodeSpec An inline image (`<img>`) node. Supports `src`,
// `alt`, and `href` attributes. The latter two default to the empty
// string.
image: {
inline: true,
attrs: {
ychange: { default: null },
src: {},
alt: { default: null },
title: { default: null }
},
group: 'inline',
draggable: true,
parseDOM: [{ tag: 'img[src]',
getAttrs (dom) {
return {
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt')
}
} }],
toDOM (node) {
const domAttrs = {
src: node.attrs.src,
title: node.attrs.title,
alt: node.attrs.alt
}
return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
}
},
// :: NodeSpec A hard line break, represented in the DOM as `<br>`.
hard_break: {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM () { return brDOM }
}
}
const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0]
// :: Object [Specs](#model.MarkSpec) for the marks in the schema.
export const marks = {
// :: MarkSpec A link. Has `href` and `title` attributes. `title`
// defaults to the empty string. Rendered and parsed as an `<a>`
// element.
link: {
attrs: {
href: {},
title: { default: null }
},
inclusive: false,
parseDOM: [{ tag: 'a[href]',
getAttrs (dom) {
return { href: dom.getAttribute('href'), title: dom.getAttribute('title') }
} }],
toDOM (node) { return ['a', node.attrs, 0] }
},
// :: MarkSpec An emphasis mark. Rendered as an `<em>` element.
// Has parse rules that also match `<i>` and `font-style: italic`.
em: {
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
toDOM () { return emDOM }
},
// :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules
// also match `<b>` and `font-weight: bold`.
strong: {
parseDOM: [{ tag: 'strong' },
// This works around a Google Docs misbehavior where
// pasted content will be inexplicably wrapped in `<b>`
// tags with a font-weight normal.
{ tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null },
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }],
toDOM () { return strongDOM }
},
// :: MarkSpec Code font mark. Represented as a `<code>` element.
code: {
parseDOM: [{ tag: 'code' }],
toDOM () { return codeDOM }
},
ychange: {
attrs: {
user: { default: null },
state: { default: null }
},
inclusive: false,
parseDOM: [{ tag: 'ychange' }],
toDOM (node) {
return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0]
}
}
}
// :: Schema
// This schema rougly corresponds to the document schema used by
// [CommonMark](http://commonmark.org/), minus the list elements,
// which are defined in the [`prosemirror-schema-list`](#schema-list)
// module.
//
// To reuse elements from this schema, extend or read from its
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
export const schema = new Schema({ nodes, marks })

View File

@@ -1,330 +0,0 @@
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection { background: transparent; }
.ProseMirror-hideselection *::-moz-selection { background: transparent; }
.ProseMirror-hideselection { caret-color: transparent; }
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px; top: -2px; bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
margin-right: 3px;
}
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
font-size: 90%;
white-space: nowrap;
}
.ProseMirror-menu-dropdown {
vertical-align: 1px;
cursor: pointer;
position: relative;
padding-right: 15px;
}
.ProseMirror-menu-dropdown-wrap {
padding: 1px 0 1px 4px;
display: inline-block;
position: relative;
}
.ProseMirror-menu-dropdown:after {
content: "";
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 2px);
}
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
position: absolute;
background: white;
color: #666;
border: 1px solid #aaa;
padding: 2px;
}
.ProseMirror-menu-dropdown-menu {
z-index: 15;
min-width: 6em;
}
.ProseMirror-menu-dropdown-item {
cursor: pointer;
padding: 2px 8px 2px 4px;
}
.ProseMirror-menu-dropdown-item:hover {
background: #f2f2f2;
}
.ProseMirror-menu-submenu-wrap {
position: relative;
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
content: "";
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 4px);
}
.ProseMirror-menu-submenu {
display: none;
min-width: 4em;
left: 100%;
top: -3px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: .3;
}
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
display: block;
}
.ProseMirror-menubar {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
position: relative;
min-height: 1em;
color: #666;
padding: 1px 6px;
top: 0; left: 0; right: 0;
border-bottom: 1px solid silver;
background: white;
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
}
.ProseMirror-icon {
display: inline-block;
line-height: .8;
vertical-align: -2px; /* Compensate for padding */
padding: 2px 8px;
cursor: pointer;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Add space around the hr to make clicking it easier */
.ProseMirror-example-setup-style hr {
padding: 2px 10px;
border: none;
margin: 1em 0;
}
.ProseMirror-example-setup-style hr:after {
content: "";
display: block;
height: 1px;
background-color: silver;
line-height: 2px;
}
.ProseMirror ul, .ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0; margin-right: 0;
}
.ProseMirror-example-setup-style img {
cursor: default;
}
.ProseMirror-prompt {
background: white;
padding: 5px 10px 5px 15px;
border: 1px solid silver;
position: fixed;
border-radius: 3px;
z-index: 11;
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
}
.ProseMirror-prompt h5 {
margin: 0;
font-weight: normal;
font-size: 100%;
color: #444;
}
.ProseMirror-prompt input[type="text"],
.ProseMirror-prompt textarea {
background: #eee;
border: none;
outline: none;
}
.ProseMirror-prompt input[type="text"] {
padding: 0 4px;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px; top: 1px;
color: #666;
border: none; background: transparent; padding: 0;
}
.ProseMirror-prompt-close:after {
content: "✕";
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
margin-top: 5px;
display: none;
}
#editor, .editor {
background: white;
color: black;
background-clip: padding-box;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.2);
padding: 5px 0;
margin-bottom: 23px;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror {
padding: 4px 8px 4px 14px;
line-height: 1.2;
outline: none;
}
.ProseMirror p { margin-bottom: 1em }

View File

@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Prosemirror Example</title>
<link rel=stylesheet href="./prosemirror.css">
<style>
placeholder {
display: inline;
border: 1px solid #ccc;
color: #ccc;
}
placeholder:after {
content: "☁";
font-size: 200%;
line-height: 0.1;
font-weight: bold;
}
.ProseMirror img { max-width: 100px }
/* this is a rough fix for the first cursor position when the first paragraph is empty */
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
margin-top: 16px;
}
.ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child {
margin-top: 16px
}
.ProseMirror-yjs-cursor {
position: absolute;
border-left: black;
border-left-style: solid;
border-left-width: 2px;
border-color: orange;
height: 1em;
word-break: normal;
}
.ProseMirror-yjs-cursor > div {
position: relative;
top: -1.05em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-family: serif;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding-left: 2px;
padding-right: 2px;
}
[ychange_state] {
position: relative;
}
[ychange_state]:hover::before {
content: attr(ychange_user);
background-color: #fa8100;
position: absolute;
top: -14px;
right: 0;
font-size: 12px;
padding: 0 2px;
border-radius: 3px 3px 0 0;
color: #fdfdfe;
user-select: none;
word-break: normal;
}
*[ychange_state='added'] {
background-color: #fa810069;
}
ychange[ychange_state='removed'] {
color: rgb(250, 129, 0);
text-decoration: line-through;
}
*:not(ychange)[ychange_state='removed'] {
background-color: #ff9494c9;
text-decoration: line-through;
}
img[ychange_state='removed'] {
padding: 2px;
}
</style>
</head>
<body>
<p>This example shows how to bind a YXmlFragment type to a <a href="http://prosemirror.net">Prosemirror</a> editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<div class="code-html">
<div id="editor" style="margin-bottom: 23px"></div>
<div style="display: none" id="content"></div>
</div>
<!-- The actual source file for the following code is found in ./prosemirror.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/prosemirror.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { prosemirrorPlugin, cursorPlugin } from 'yjs/bindings/prosemirror'
import * as conf from './exampleConfig.js'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { exampleSetup } from 'prosemirror-example-setup'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror')
const type = ydocument.define('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin])
})
})
window.example = { provider, ydocument, type, prosemirrorView }
</script>
</body>
</html>

View File

@@ -1,25 +0,0 @@
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { prosemirrorPlugin, cursorPlugin } from 'y-prosemirror'
import * as conf from './exampleConfig.js'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { DOMParser } from 'prosemirror-model'
import { schema } from './prosemirror-schema.js'
import { exampleSetup } from 'prosemirror-example-setup'
// import { noteHistoryPlugin } from './prosemirror-history.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror', { gc: false })
const type = ydocument.define('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({ schema }).concat([prosemirrorPlugin(type), cursorPlugin])
})
})
window.example = { provider, ydocument, type, prosemirrorView }

View File

@@ -1,51 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Quill Example</title>
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css">
</head>
<body>
<p>This example shows how to bind a YText type to <a href="https://quilljs.com">Quill</a> editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<div class="code-html">
<div id="quill-container">
<div id="quill">
</div>
</div>
</div>
<!-- The actual source file for the following code is found in ./quill.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/quill.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { QuillBinding } from 'yjs/bindings/quill.js'
import * as conf from './exampleConfig.js'
import Quill from 'quill'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('quill')
const ytext = ydocument.define('quill', Y.Text)
const quill = new Quill('#quill-container', {
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
})
window.quillBinding = new QuillBinding(ytext, quill)
</script>
</body>
</html>

View File

@@ -1,30 +0,0 @@
import * as Y from '../src/index.js'
import { WebsocketProvider } from 'y-websocket'
import { QuillBinding } from 'y-quill'
import * as conf from './exampleConfig.js'
import Quill from 'quill'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('quill')
const ytext = ydocument.define('quill', Y.Text)
const quill = new Quill('#quill-container', {
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
})
window.quillBinding = new QuillBinding(ytext, quill)

View File

@@ -1,29 +0,0 @@
footer img {
display: none;
}
nav .title h1 a {
display: none;
}
footer {
background-color: #b93c1d;
}
#resizer {
background-color: #b93c1d;
}
.main section article.readme h1:first-child img {
display: none;
}
.main section article.readme h1:first-child {
margin-bottom: 16px;
margin-top: 30px;
}
.main section article.readme h1:first-child::before {
content: "Yjs";
font-size: 2em;
}

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Textarea Example</title>
</head>
<body>
<p>This example shows how to bind a YText type to a DOM Textarea.</p>
<p>The content of this textarea is shared with every client who visits this domain.</p>
<div class="code-html">
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- The actual source file for the following code is found in ./textarea.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/textarea.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { TextareaBinding } from 'yjs/bindings/textarea.js'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('textarea')
const type = ydocument.define('textarea', Y.Text)
const textarea = document.querySelector('textarea')
const binding = new TextareaBinding(type, textarea)
window.textareaExample = {
provider, ydocument, type, textarea, binding
}
</script>
</body>
</html>

View File

@@ -1,14 +0,0 @@
import { WebsocketProvider } from 'y-websocket'
import { TextareaBinding } from 'y-textarea'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('textarea')
const type = ydocument.getText('textarea')
const textarea = document.querySelector('textarea')
const binding = new TextareaBinding(type, textarea)
window.textareaExample = {
provider, ydocument, type, textarea, binding
}

6889
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +1,75 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.0-78", "version": "13.0.3",
"description": "A ", "description": "Shared Editing Library",
"main": "./dist/yjs.js", "main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs'", "module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production", "test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000", "test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
"dist": "rm -rf dist examples/build && rollup -c", "dist": "rm -rf dist && rollup -c && tsc",
"watch": "rollup -wc", "watch": "rollup -wc",
"lint": "standard && tsc", "lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.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 && serve ./docs/", "serve-docs": "npm run docs && http-server ./docs/",
"postversion": "npm run lint && PRODUCTION=1 npm run dist && node ./dist/tests.js --repitition-time 1000", "preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000",
"debug": "concurrently 'live-server --port=3443 --entry-file=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.js", "trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js" "trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
}, },
"files": [ "files": [
"dist/*", "dist/*",
"examples/*", "src/*",
"docs/*", "tests/*",
"README.md", "docs/*"
"LICENSE"
], ],
"dictionaries": { "dictionaries": {
"doc": "docs", "doc": "docs",
"example": "examples",
"test": "tests" "test": "tests"
}, },
"standard": { "standard": {
"ignore": [ "ignore": [
"/dist", "/dist",
"/node_modules", "/node_modules",
"/docs", "/docs"
"/examples/build"
] ]
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/y-js/yjs.git" "url": "https://github.com/yjs/yjs.git"
}, },
"keywords": [ "keywords": [
"crdt" "Yjs",
"CRDT",
"offline",
"shared editing",
"concurrency",
"collaboration"
], ],
"author": "Kevin Jahns", "author": "Kevin Jahns",
"email": "kevin.jahns@rwth-aachen.de", "email": "kevin.jahns@protonmail.com",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/y-js/yjs/issues" "url": "https://github.com/yjs/yjs/issues"
}, },
"homepage": "http://y-js.org", "homepage": "https://yjs.dev",
"dependencies": { "dependencies": {
"lib0": "0.0.0" "lib0": "^0.2.12"
}, },
"devDependencies": { "devDependencies": {
"codemirror": "^5.42.0", "@rollup/plugin-commonjs": "^11.0.1",
"@rollup/plugin-node-resolve": "^7.0.0",
"concurrently": "^3.6.1", "concurrently": "^3.6.1",
"esdoc": "^1.1.0", "http-server": "^0.12.1",
"esdoc-standard-plugin": "^1.0.0", "jsdoc": "^3.6.3",
"jsdoc": "^3.5.5", "markdownlint-cli": "^0.19.0",
"live-server": "^1.2.1", "rollup": "^1.30.0",
"prosemirror-example-setup": "^1.0.1",
"prosemirror-schema-basic": "^1.0.0",
"prosemirror-state": "^1.2.2",
"prosemirror-view": "^1.6.5",
"quill": "^1.3.6",
"quill-cursors": "^1.0.3",
"rollup": "^1.1.2",
"rollup-cli": "^1.0.9", "rollup-cli": "^1.0.9",
"rollup-plugin-commonjs": "^9.2.0", "standard": "^14.0.0",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-terser": "^4.0.4",
"standard": "^11.0.1",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^3.3.3333" "typescript": "^3.7.5",
"y-protocols": "^0.2.0"
} }
} }

View File

@@ -1,6 +1,7 @@
import nodeResolve from 'rollup-plugin-node-resolve' import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs' import commonjs from '@rollup/plugin-commonjs'
import { terser } from 'rollup-plugin-terser'
const localImports = process.env.LOCALIMPORTS
const customModules = new Set([ const customModules = new Set([
'y-websocket', 'y-websocket',
@@ -23,50 +24,47 @@ const debugResolve = {
if (importee === 'yjs') { if (importee === 'yjs') {
return `${process.cwd()}/src/index.js` return `${process.cwd()}/src/index.js`
} }
if (customModules.has(importee.split('/')[0])) { if (localImports) {
return `${process.cwd()}/../${importee}/src/${importee}.js` if (customModules.has(importee.split('/')[0])) {
} return `${process.cwd()}/../${importee}/src/${importee}.js`
if (customLibModules.has(importee.split('/')[0])) { }
return `${process.cwd()}/../${importee}` if (customLibModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}`
}
} }
return null return null
} }
} }
const minificationPlugins = process.env.PRODUCTION ? [terser({
module: true,
compress: {
hoist_vars: true,
module: true,
passes: 5,
pure_getters: true,
unsafe_comps: true,
unsafe_undefined: true
},
mangle: {
toplevel: true
}
})] : []
export default [{ export default [{
input: './src/index.js', input: './src/index.js',
output: [{ output: {
name: 'Y', name: 'Y',
file: 'dist/yjs.js', file: 'dist/yjs.cjs',
format: 'cjs', format: 'cjs',
sourcemap: true, sourcemap: true,
paths: path => { paths: path => {
if (/^lib0\//.test(path)) { if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5)}` return `lib0/dist/${path.slice(5, -3)}.cjs`
} }
return path return path
} }
}, { },
external: id => /^lib0\//.test(id)
}, {
input: './src/index.js',
output: {
name: 'Y', name: 'Y',
file: 'dist/yjs.mjs', file: 'dist/yjs.mjs',
format: 'es', format: 'esm',
sourcemap: true sourcemap: true,
}], paths: path => {
if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5, -3)}.cjs`
}
return path
}
},
external: id => /^lib0\//.test(id) external: id => /^lib0\//.test(id)
}, { }, {
input: './tests/index.js', input: './tests/index.js',
@@ -80,26 +78,25 @@ export default [{
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
sourcemap: true, sourcemap: true,
module: true, mainFields: ['module', 'browser', 'main']
browser: true
}), }),
commonjs() commonjs()
] ]
}, { }, {
input: ['./examples/codemirror.js', './examples/textarea.js'], // './examples/quill.js', './examples/dom.js', './examples/prosemirror.js' input: './tests/index.js',
output: { output: {
dir: 'examples/build', name: 'test',
format: 'esm', file: 'dist/tests.cjs',
format: 'cjs',
sourcemap: true sourcemap: true
}, },
plugins: [ plugins: [
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
sourcemap: true, sourcemap: true,
module: true, mainFields: ['module', 'main']
browser: true
}), }),
commonjs(), commonjs()
...minificationPlugins ],
] external: ['isomorphic.js']
}] }]

View File

@@ -1,6 +1,6 @@
export { export {
Y, Doc,
Transaction, Transaction,
YArray as Array, YArray as Array,
YMap as Map, YMap as Map,
@@ -9,18 +9,53 @@ export {
YXmlHook as XmlHook, YXmlHook as XmlHook,
YXmlElement as XmlElement, YXmlElement as XmlElement,
YXmlFragment as XmlFragment, YXmlFragment as XmlFragment,
createCursorFromTypeOffset, YXmlEvent,
createCursorFromJSON, YMapEvent,
createAbsolutePositionFromCursor, YArrayEvent,
writeCursor, YEvent,
readCursor, Item,
AbstractStruct,
GC,
ContentBinary,
ContentDeleted,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentAny,
ContentString,
ContentType,
AbstractType,
RelativePosition,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
writeRelativePosition,
readRelativePosition,
ID, ID,
createID, createID,
compareIDs, compareIDs,
getState, getState,
getStates, Snapshot,
readStatesAsMap, createSnapshot,
writeStates, createDeleteSet,
writeModel, createDeleteSetFromStructStore,
readModel snapshot,
emptySnapshot,
findRootTypeKey,
typeListToArraySnapshot,
typeMapGetSnapshot,
iterateDeletedStructs,
applyUpdate,
encodeStateAsUpdate,
encodeStateVector,
UndoManager,
decodeSnapshot,
encodeSnapshot,
isDeleted,
isParentOf,
equalSnapshots,
PermanentUserData, // @TODO experimental
tryGc,
transact
} from './internals.js' } from './internals.js'

View File

@@ -1,33 +1,36 @@
export * from './utils/DeleteSet.js' export * from './utils/DeleteSet.js'
export * from './utils/Doc.js'
export * from './utils/encoding.js'
export * from './utils/EventHandler.js' export * from './utils/EventHandler.js'
export * from './utils/ID.js' export * from './utils/ID.js'
export * from './utils/isParentOf.js' export * from './utils/isParentOf.js'
export * from './utils/cursor.js' export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js' export * from './utils/Snapshot.js'
export * from './utils/StructStore.js' export * from './utils/StructStore.js'
export * from './utils/Transaction.js' export * from './utils/Transaction.js'
// export * from './utils/UndoManager.js' export * from './utils/UndoManager.js'
export * from './utils/Y.js'
export * from './utils/YEvent.js' export * from './utils/YEvent.js'
export * from './types/AbstractType.js' export * from './types/AbstractType.js'
export * from './types/YArray.js' export * from './types/YArray.js'
export * from './types/YMap.js' export * from './types/YMap.js'
export * from './types/YText.js' export * from './types/YText.js'
export * from './types/YXmlFragment.js'
export * from './types/YXmlElement.js' export * from './types/YXmlElement.js'
export * from './types/YXmlEvent.js' export * from './types/YXmlEvent.js'
export * from './types/YXmlHook.js' export * from './types/YXmlHook.js'
export * from './types/YXmlText.js' export * from './types/YXmlText.js'
export * from './structs/AbstractStruct.js' export * from './structs/AbstractStruct.js'
export * from './structs/AbstractItem.js'
export * from './structs/GC.js' export * from './structs/GC.js'
export * from './structs/ItemBinary.js' export * from './structs/ContentBinary.js'
export * from './structs/ItemDeleted.js' export * from './structs/ContentDeleted.js'
export * from './structs/ItemEmbed.js' export * from './structs/ContentEmbed.js'
export * from './structs/ItemFormat.js' export * from './structs/ContentFormat.js'
export * from './structs/ItemJSON.js' export * from './structs/ContentJSON.js'
export * from './structs/ItemString.js' export * from './structs/ContentAny.js'
export * from './structs/ItemType.js' export * from './structs/ContentString.js'
export * from './structs/ContentType.js'
export * from './utils/encoding.js' export * from './structs/Item.js'

View File

@@ -1,6 +1,6 @@
import { import {
Y, StructStore, ID, Transaction // eslint-disable-line StructStore, ID, Transaction // 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
@@ -12,16 +12,19 @@ import * as error from 'lib0/error.js'
export class AbstractStruct { export class AbstractStruct {
/** /**
* @param {ID} id * @param {ID} id
* @param {number} length
*/ */
constructor (id) { constructor (id, length) {
/** /**
* The uniqe identifier of this struct. * The uniqe identifier of this struct.
* @type {ID} * @type {ID}
* @readonly * @readonly
*/ */
this.id = id this.id = id
this.length = length
this.deleted = false this.deleted = false
} }
/** /**
* Merge this struct with the item to the right. * Merge this struct with the item to the right.
* This method is already assuming that `this.id.clock + this.length === this.id.clock`. * This method is already assuming that `this.id.clock + this.length === this.id.clock`.
@@ -32,12 +35,7 @@ export class AbstractStruct {
mergeWith (right) { mergeWith (right) {
return false return false
} }
/**
* @type {number}
*/
get length () {
throw error.methodUnimplemented()
}
/** /**
* @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
@@ -47,6 +45,7 @@ export class AbstractStruct {
write (encoder, offset, encodingRef) { write (encoder, offset, encodingRef) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
*/ */
@@ -73,6 +72,7 @@ export class AbstractStructRef {
*/ */
this.id = id this.id = id
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @return {Array<ID|null>} * @return {Array<ID|null>}
@@ -80,6 +80,7 @@ export class AbstractStructRef {
getMissing (transaction) { getMissing (transaction) {
return this._missing return this._missing
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
@@ -89,10 +90,4 @@ export class AbstractStructRef {
toStruct (transaction, store, offset) { toStruct (transaction, store, offset) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/**
* @type {number}
*/
get length () {
return 1
}
} }

116
src/structs/ContentAny.js Normal file
View File

@@ -0,0 +1,116 @@
import {
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentAny {
/**
* @param {Array<any>} arr
*/
constructor (arr) {
/**
* @type {Array<any>}
*/
this.arr = arr
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentAny}
*/
copy () {
return new ContentAny(this.arr)
}
/**
* @param {number} offset
* @return {ContentAny}
*/
splice (offset) {
const right = new ContentAny(this.arr.slice(offset))
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentAny} right
* @return {boolean}
*/
mergeWith (right) {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoding.writeAny(encoder, c)
}
}
/**
* @return {number}
*/
getRef () {
return 8
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentAny}
*/
export const readContentAny = decoder => {
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
cs.push(decoding.readAny(decoder))
}
return new ContentAny(cs)
}

View File

@@ -0,0 +1,100 @@
import {
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as buffer from 'lib0/buffer.js'
import * as error from 'lib0/error.js'
/**
* @private
*/
export class ContentBinary {
/**
* @param {Uint8Array} content
*/
constructor (content) {
this.content = content
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.content]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentBinary}
*/
copy () {
return new ContentBinary(this.content)
}
/**
* @param {number} offset
* @return {ContentBinary}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentBinary} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoding.writeVarUint8Array(encoder, this.content)
}
/**
* @return {number}
*/
getRef () {
return 3
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentBinary}
*/
export const readContentBinary = decoder => new ContentBinary(buffer.copyUint8Array(decoding.readVarUint8Array(decoder)))

View File

@@ -0,0 +1,107 @@
import {
addToDeleteSet,
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentDeleted {
/**
* @param {number} len
*/
constructor (len) {
this.len = len
}
/**
* @return {number}
*/
getLength () {
return this.len
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentDeleted}
*/
copy () {
return new ContentDeleted(this.len)
}
/**
* @param {number} offset
* @return {ContentDeleted}
*/
splice (offset) {
const right = new ContentDeleted(this.len - offset)
this.len = offset
return right
}
/**
* @param {ContentDeleted} right
* @return {boolean}
*/
mergeWith (right) {
this.len += right.len
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
addToDeleteSet(transaction.deleteSet, item.id, this.len)
item.deleted = true
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoding.writeVarUint(encoder, this.len - offset)
}
/**
* @return {number}
*/
getRef () {
return 1
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentDeleted}
*/
export const readContentDeleted = decoder => new ContentDeleted(decoding.readVarUint(decoder))

100
src/structs/ContentEmbed.js Normal file
View File

@@ -0,0 +1,100 @@
import {
StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @private
*/
export class ContentEmbed {
/**
* @param {Object} embed
*/
constructor (embed) {
this.embed = embed
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.embed]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentEmbed}
*/
copy () {
return new ContentEmbed(this.embed)
}
/**
* @param {number} offset
* @return {ContentEmbed}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentEmbed} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoding.writeVarString(encoder, JSON.stringify(this.embed))
}
/**
* @return {number}
*/
getRef () {
return 5
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentEmbed}
*/
export const readContentEmbed = decoder => new ContentEmbed(JSON.parse(decoding.readVarString(decoder)))

View File

@@ -0,0 +1,103 @@
import {
Item, StructStore, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @private
*/
export class ContentFormat {
/**
* @param {string} key
* @param {Object} value
*/
constructor (key, value) {
this.key = key
this.value = value
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentFormat}
*/
copy () {
return new ContentFormat(this.key, this.value)
}
/**
* @param {number} offset
* @return {ContentFormat}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentFormat} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoding.writeVarString(encoder, this.key)
encoding.writeVarString(encoder, JSON.stringify(this.value))
}
/**
* @return {number}
*/
getRef () {
return 6
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentFormat}
*/
export const readContentFormat = decoder => new ContentFormat(decoding.readVarString(decoder), JSON.parse(decoding.readVarString(decoder)))

121
src/structs/ContentJSON.js Normal file
View File

@@ -0,0 +1,121 @@
import {
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentJSON {
/**
* @param {Array<any>} arr
*/
constructor (arr) {
/**
* @type {Array<any>}
*/
this.arr = arr
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentJSON}
*/
copy () {
return new ContentJSON(this.arr)
}
/**
* @param {number} offset
* @return {ContentJSON}
*/
splice (offset) {
const right = new ContentJSON(this.arr.slice(offset))
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentJSON} right
* @return {boolean}
*/
mergeWith (right) {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
}
}
/**
* @return {number}
*/
getRef () {
return 2
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentJSON}
*/
export const readContentJSON = decoder => {
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
const c = decoding.readVarString(decoder)
if (c === 'undefined') {
cs.push(undefined)
} else {
cs.push(JSON.parse(c))
}
}
return new ContentJSON(cs)
}

View File

@@ -0,0 +1,104 @@
import {
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentString {
/**
* @param {string} str
*/
constructor (str) {
/**
* @type {string}
*/
this.str = str
}
/**
* @return {number}
*/
getLength () {
return this.str.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.str.split('')
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentString}
*/
copy () {
return new ContentString(this.str)
}
/**
* @param {number} offset
* @return {ContentString}
*/
splice (offset) {
const right = new ContentString(this.str.slice(offset))
this.str = this.str.slice(0, offset)
return right
}
/**
* @param {ContentString} right
* @return {boolean}
*/
mergeWith (right) {
this.str += right.str
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoding.writeVarString(encoder, offset === 0 ? this.str : this.str.slice(offset))
}
/**
* @return {number}
*/
getRef () {
return 4
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentString}
*/
export const readContentString = decoder => new ContentString(decoding.readVarString(decoder))

174
src/structs/ContentType.js Normal file
View File

@@ -0,0 +1,174 @@
import {
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText,
StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* @type {Array<function(decoding.Decoder):AbstractType<any>>}
* @private
*/
export const typeRefs = [
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText
]
export const YArrayRefID = 0
export const YMapRefID = 1
export const YTextRefID = 2
export const YXmlElementRefID = 3
export const YXmlFragmentRefID = 4
export const YXmlHookRefID = 5
export const YXmlTextRefID = 6
/**
* @private
*/
export class ContentType {
/**
* @param {AbstractType<YEvent>} type
*/
constructor (type) {
/**
* @type {AbstractType<any>}
*/
this.type = type
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.type]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentType}
*/
copy () {
return new ContentType(this.type._copy())
}
/**
* @param {number} offset
* @return {ContentType}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentType} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
this.type._integrate(transaction.doc, item)
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
let item = this.type._start
while (item !== null) {
if (!item.deleted) {
item.delete(transaction)
} else {
// Whis will be gc'd later and we want to merge it if possible
// We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
transaction._mergeStructs.add(item.id)
}
item = item.right
}
this.type._map.forEach(item => {
if (!item.deleted) {
item.delete(transaction)
} else {
// same as above
transaction._mergeStructs.add(item.id)
}
})
transaction.changed.delete(this.type)
}
/**
* @param {StructStore} store
*/
gc (store) {
let item = this.type._start
while (item !== null) {
item.gc(store, true)
item = item.right
}
this.type._start = null
this.type._map.forEach(/** @param {Item | null} item */ (item) => {
while (item !== null) {
item.gc(store, true)
item = item.left
}
})
this.type._map = new Map()
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
this.type._write(encoder)
}
/**
* @return {number}
*/
getRef () {
return 7
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentType}
*/
export const readContentType = decoder => new ContentType(typeRefs[decoding.readVarUint(decoder)](decoder))

View File

@@ -4,7 +4,7 @@ import {
AbstractStruct, AbstractStruct,
createID, createID,
addStruct, addStruct,
Y, StructStore, Transaction, ID // eslint-disable-line StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
@@ -21,26 +21,18 @@ export class GC extends AbstractStruct {
* @param {number} length * @param {number} length
*/ */
constructor (id, length) { constructor (id, length) {
super(id) super(id, length)
/**
* @type {number}
*/
this._len = length
this.deleted = true this.deleted = true
} }
get length () {
return this._len
}
delete () {} delete () {}
/** /**
* @param {AbstractStruct} right * @param {GC} right
* @return {boolean} * @return {boolean}
*/ */
mergeWith (right) { mergeWith (right) {
this._len += right.length this.length += right.length
return true return true
} }
@@ -48,7 +40,7 @@ export class GC extends AbstractStruct {
* @param {Transaction} transaction * @param {Transaction} transaction
*/ */
integrate (transaction) { integrate (transaction) {
addStruct(transaction.y.store, this) addStruct(transaction.doc.store, this)
} }
/** /**
@@ -57,7 +49,7 @@ export class GC extends AbstractStruct {
*/ */
write (encoder, offset) { write (encoder, offset) {
encoding.writeUint8(encoder, structGCRefNumber) encoding.writeUint8(encoder, structGCRefNumber)
encoding.writeVarUint(encoder, this._len - offset) encoding.writeVarUint(encoder, this.length - offset)
} }
} }
@@ -75,16 +67,9 @@ export class GCRef extends AbstractStructRef {
/** /**
* @type {number} * @type {number}
*/ */
this._len = decoding.readVarUint(decoder) this.length = decoding.readVarUint(decoder)
}
get length () {
return this._len
}
missing () {
return [
createID(this.id.client, this.id.clock - 1)
]
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
@@ -95,11 +80,11 @@ export class GCRef extends AbstractStructRef {
if (offset > 0) { if (offset > 0) {
// @ts-ignore // @ts-ignore
this.id = createID(this.id.client, this.id.clock + offset) this.id = createID(this.id.client, this.id.clock + offset)
this._len = this._len - offset this.length -= offset
} }
return new GC( return new GC(
this.id, this.id,
this._len this.length
) )
} }
} }

View File

@@ -10,14 +10,21 @@ import {
replaceStruct, replaceStruct,
addStruct, addStruct,
addToDeleteSet, addToDeleteSet,
ItemDeleted,
findRootTypeKey, findRootTypeKey,
compareIDs, compareIDs,
getItem, getItem,
getItemType,
getItemCleanEnd, getItemCleanEnd,
getItemCleanStart, getItemCleanStart,
YEvent, StructStore, ID, AbstractType, Y, Transaction // eslint-disable-line readContentDeleted,
readContentBinary,
readContentJSON,
readContentAny,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
addChangedTypeToTransaction,
ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
@@ -27,12 +34,55 @@ import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js' import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js' import * as binary from 'lib0/binary.js'
/**
* @todo This should return several items
*
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
*/
export const followRedone = (store, id) => {
/**
* @type {ID|null}
*/
let nextID = id
let diff = 0
let item
do {
if (diff > 0) {
nextID = createID(nextID.client, nextID.clock + diff)
}
item = getItem(store, nextID)
diff = nextID.clock - item.id.clock
nextID = item.redone
} while (nextID !== null && item instanceof Item)
return {
item, diff
}
}
/**
* Make sure that neither item nor any of its parents is ever deleted.
*
* This property does not persist when storing it into a database or when
* sending it to other peers
*
* @param {Item|null} item
* @param {boolean} keep
*/
export const keepItem = (item, keep) => {
while (item !== null && item.keep !== keep) {
item.keep = keep
item = item.parent._item
}
}
/** /**
* Split leftItem into two items * Split leftItem into two items
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractItem} leftItem * @param {Item} leftItem
* @param {number} diff * @param {number} diff
* @return {AbstractItem} * @return {Item}
* *
* @function * @function
* @private * @private
@@ -40,18 +90,25 @@ import * as binary from 'lib0/binary.js'
export const splitItem = (transaction, leftItem, diff) => { export const splitItem = (transaction, leftItem, diff) => {
const id = leftItem.id const id = leftItem.id
// create rightItem // create rightItem
const rightItem = leftItem.copy( const rightItem = new Item(
createID(id.client, id.clock + diff), createID(id.client, id.clock + diff),
leftItem, leftItem,
createID(id.client, id.clock + diff - 1), createID(id.client, id.clock + diff - 1),
leftItem.right, leftItem.right,
leftItem.rightOrigin, leftItem.rightOrigin,
leftItem.parent, leftItem.parent,
leftItem.parentSub leftItem.parentSub,
leftItem.content.splice(diff)
) )
if (leftItem.deleted) { if (leftItem.deleted) {
rightItem.deleted = true rightItem.deleted = true
} }
if (leftItem.keep) {
rightItem.keep = true
}
if (leftItem.redone !== null) {
rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff)
}
// update left (do not set leftItem.rightOrigin as it will lead to problems when syncing) // update left (do not set leftItem.rightOrigin as it will lead to problems when syncing)
leftItem.right = rightItem leftItem.right = rightItem
// update right // update right
@@ -60,24 +117,131 @@ export const splitItem = (transaction, leftItem, diff) => {
} }
// right is more specific. // right is more specific.
transaction._mergeStructs.add(rightItem.id) transaction._mergeStructs.add(rightItem.id)
// update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) {
rightItem.parent._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
return rightItem return rightItem
} }
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems) => {
if (item.redone !== null) {
return getItemCleanStart(transaction, item.redone)
}
let parentItem = item.parent._item
/**
* @type {Item|null}
*/
let left
/**
* @type {Item|null}
*/
let right
if (item.parentSub === null) {
// Is an array item. Insert at the old position
left = item.left
right = item
} else {
// Is a map item. Insert as current value
left = item
while (left.right !== null) {
left = left.right
if (left.id.client !== transaction.doc.clientID) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
}
if (left.right !== null) {
left = /** @type {Item} */ (item.parent._map.get(item.parentSub))
}
right = null
}
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) {
return null
}
}
if (parentItem !== null && parentItem.redone !== null) {
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
// find next cloned_redo items
while (left !== null) {
/**
* @type {Item|null}
*/
let leftTrace = left
// trace redone until parent matches
while (leftTrace !== null && leftTrace.parent._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && leftTrace.parent._item === parentItem) {
left = leftTrace
break
}
left = left.left
}
while (right !== null) {
/**
* @type {Item|null}
*/
let rightTrace = right
// trace redone until parent matches
while (rightTrace !== null && rightTrace.parent._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && rightTrace.parent._item === parentItem) {
right = rightTrace
break
}
right = right.right
}
}
const redoneItem = new Item(
nextID(transaction),
left, left === null ? null : left.lastId,
right, right === null ? null : right.id,
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
item.parentSub,
item.content.copy()
)
item.redone = redoneItem.id
keepItem(redoneItem, true)
redoneItem.integrate(transaction)
return redoneItem
}
/** /**
* Abstract class that represents any content. * Abstract class that represents any content.
*/ */
export class AbstractItem extends AbstractStruct { export class Item extends AbstractStruct {
/** /**
* @param {ID} id * @param {ID} id
* @param {AbstractItem | null} left * @param {Item | null} left
* @param {ID | null} origin * @param {ID | null} origin
* @param {AbstractItem | null} right * @param {Item | null} right
* @param {ID | null} rightOrigin * @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {string | null} parentSub * @param {string | null} parentSub
* @param {AbstractContent} content
*/ */
constructor (id, left, origin, right, rightOrigin, parent, parentSub) { constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
super(id) super(id, content.getLength())
/** /**
* The item that was originally to the left of this item. * The item that was originally to the left of this item.
* @type {ID | null} * @type {ID | null}
@@ -86,12 +250,12 @@ export class AbstractItem extends AbstractStruct {
this.origin = origin this.origin = origin
/** /**
* The item that is currently to the left of this item. * The item that is currently to the left of this item.
* @type {AbstractItem | null} * @type {Item | null}
*/ */
this.left = left this.left = left
/** /**
* The item that is currently to the right of this item. * The item that is currently to the right of this item.
* @type {AbstractItem | null} * @type {Item | null}
*/ */
this.right = right this.right = right
/** /**
@@ -123,9 +287,19 @@ export class AbstractItem extends AbstractStruct {
/** /**
* If this type's effect is reundone this type refers to the type that undid * If this type's effect is reundone this type refers to the type that undid
* this operation. * this operation.
* @type {AbstractItem | null} * @type {ID | null}
*/ */
this.redone = null this.redone = null
/**
* @type {AbstractContent}
*/
this.content = content
this.length = content.getLength()
this.countable = content.isCountable()
/**
* If true, do not garbage collect this Item.
*/
this.keep = false
} }
/** /**
@@ -133,13 +307,13 @@ export class AbstractItem extends AbstractStruct {
* @private * @private
*/ */
integrate (transaction) { integrate (transaction) {
const store = transaction.y.store const store = transaction.doc.store
const id = this.id 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 {AbstractItem|null} * @type {Item|null}
*/ */
let o let o
// set o to the first conflicting item // set o to the first conflicting item
@@ -155,11 +329,11 @@ export class AbstractItem extends AbstractStruct {
} }
// TODO: use something like DeleteSet here (a tree implementation would be best) // TODO: use something like DeleteSet here (a tree implementation would be best)
/** /**
* @type {Set<AbstractItem>} * @type {Set<Item>}
*/ */
const conflictingItems = new Set() const conflictingItems = new Set()
/** /**
* @type {Set<AbstractItem>} * @type {Set<Item>}
*/ */
const itemsBeforeOrigin = new Set() const itemsBeforeOrigin = new Set()
// Let c in conflictingItems, b in itemsBeforeOrigin // Let c in conflictingItems, b in itemsBeforeOrigin
@@ -218,8 +392,9 @@ export class AbstractItem extends AbstractStruct {
parent._length += length parent._length += length
} }
addStruct(store, this) addStruct(store, this)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(parentSub) this.content.integrate(transaction, this)
// @ts-ignore // add parent to transaction.changed
addChangedTypeToTransaction(transaction, parent, parentSub)
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) { if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent // delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction) this.delete(transaction)
@@ -250,84 +425,6 @@ export class AbstractItem extends AbstractStruct {
return n return n
} }
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @return {AbstractItem}
*
* @private
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
throw new Error('unimplemented')
}
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Set<AbstractItem>} redoitems
*
* @private
*/
redo (transaction, redoitems) {
if (this.redone !== null) {
return this.redone
}
/**
* @type {any}
*/
let parent = this.parent
if (parent === null) {
return
}
let left, right
if (this.parentSub === null) {
// Is an array item. Insert at the old position
left = this.left
right = this
} else {
// Is a map item. Insert as current value
left = parent.type._map.get(this.parentSub)
right = null
}
// make sure that parent is redone
if (parent._deleted === true && parent.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parent) || !parent.redo(transaction, redoitems)) {
return false
}
}
if (parent.redone !== null) {
while (parent.redone !== null) {
parent = parent.redone
}
// find next cloned_redo items
while (left !== null) {
if (left.redone !== null && left.redone.parent === parent) {
left = left.redone
break
}
left = left.left
}
while (right !== null) {
if (right.redone !== null && right.redone.parent === parent) {
right = right.redone
}
right = right.right
}
}
this.redone = this.copy(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, this.parentSub)
this.redone.integrate(transaction)
return true
}
/** /**
* Computes the last content address of this Item. * Computes the last content address of this Item.
*/ */
@@ -336,62 +433,37 @@ export class AbstractItem extends AbstractStruct {
} }
/** /**
* Computes the length of this Item. * Try to merge two items
*/
get length () {
return 1
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
* *
* * Whether this Item should be addressable via `yarray.get(i)` * @param {Item} right
* * Whether this Item should be counted when computing yarray.length
*/
get countable () {
return true
}
/**
* Do not call directly. Always split via StructStore!
*
* Splits this Item so that another Item can be inserted in-between.
* This must be overwritten if _length > 1
* Returns right part after split
*
* (see {@link ItemJSON}/{@link ItemString} for implementation)
*
* Does not integrate the struct, nor store it in struct store.
*
* This method should only be cally by StructStore.
*
* @param {Transaction} transaction
* @param {number} diff
* @return {AbstractItem}
*
* @private
*/
splitAt (transaction, diff) {
throw new Error('unimplemented')
}
/**
* @param {AbstractItem} right
* @return {boolean} * @return {boolean}
*
* @private
*/ */
mergeWith (right) { mergeWith (right) {
if (compareIDs(right.origin, this.lastId) && this.right === right && compareIDs(this.rightOrigin, right.rightOrigin)) { if (
compareIDs(right.origin, this.lastId) &&
this.right === right &&
compareIDs(this.rightOrigin, right.rightOrigin) &&
this.id.client === right.id.client &&
this.id.clock + this.length === right.id.clock &&
this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content)
) {
if (right.keep) {
this.keep = true
}
this.right = right.right this.right = right.right
if (this.right !== null) { if (this.right !== null) {
this.right.left = this this.right.left = this
} }
this.length += right.length
return true return true
} }
return false return false
} }
/** /**
* Mark this Item as deleted. * Mark this Item as deleted.
* *
@@ -407,49 +479,26 @@ export class AbstractItem extends AbstractStruct {
this.deleted = true this.deleted = true
addToDeleteSet(transaction.deleteSet, this.id, this.length) addToDeleteSet(transaction.deleteSet, this.id, this.length)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub) maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
this.content.delete(transaction)
} }
} }
/** /**
* @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
* @param {boolean} parentGCd
* *
* @private * @private
*/ */
gcChildren (transaction, store) { } gc (store, parentGCd) {
if (!this.deleted) {
/** throw error.unexpectedCase()
* @param {Transaction} transaction }
* @param {StructStore} store this.content.gc(store)
* if (parentGCd) {
* @private replaceStruct(store, this, new GC(this.id, this.length))
*/
gc (transaction, store) {
let r
if (this.parent._item !== null && this.parent._item.deleted) {
r = new GC(this.id, this.length)
} else { } else {
r = new ItemDeleted(this.id, this.left, this.origin, this.right, this.rightOrigin, this.parent, this.parentSub, this.length) this.content = new ContentDeleted(this.length)
if (r.right !== null) {
r.right.left = r
} else if (r.parentSub !== null) {
r.parent._map.set(r.parentSub, r)
}
if (r.left !== null) {
r.left.right = r
} else if (r.parentSub === null) {
r.parent._start = r
}
} }
replaceStruct(store, this, r)
transaction._mergeStructs.add(r.id)
}
/**
* @return {Array<any>}
*/
getContent () {
throw error.methodUnimplemented()
} }
/** /**
@@ -460,15 +509,14 @@ export class AbstractItem 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
* @param {number} encodingRef
* *
* @private * @private
*/ */
write (encoder, offset, encodingRef) { 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
const rightOrigin = this.rightOrigin const rightOrigin = this.rightOrigin
const parentSub = this.parentSub const parentSub = this.parentSub
const info = (encodingRef & binary.BITS5) | const info = (this.content.getRef() & binary.BITS5) |
(origin === null ? 0 : binary.BIT8) | // origin is defined (origin === null ? 0 : binary.BIT8) | // origin is defined
(rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined (rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined
(parentSub === null ? 0 : binary.BIT6) // parentSub is non-null (parentSub === null ? 0 : binary.BIT6) // parentSub is non-null
@@ -484,26 +532,140 @@ export class AbstractItem extends AbstractStruct {
if (parent._item === null) { if (parent._item === null) {
// parent type on y._map // parent type on y._map
// find the correct key // find the correct key
// @ts-ignore we know that y exists
const ykey = findRootTypeKey(parent) const ykey = findRootTypeKey(parent)
encoding.writeVarUint(encoder, 1) // write parentYKey encoding.writeVarUint(encoder, 1) // write parentYKey
encoding.writeVarString(encoder, ykey) encoding.writeVarString(encoder, ykey)
} else { } else {
encoding.writeVarUint(encoder, 0) // write parent id encoding.writeVarUint(encoder, 0) // write parent id
// @ts-ignore _item is defined because parent is integrated
writeID(encoder, parent._item.id) writeID(encoder, parent._item.id)
} }
if (parentSub !== null) { if (parentSub !== null) {
encoding.writeVarString(encoder, parentSub) encoding.writeVarString(encoder, parentSub)
} }
} }
this.content.write(encoder, offset)
}
}
/**
* @param {decoding.Decoder} decoder
* @param {number} info
*/
const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
/**
* A lookup map for reading Item content.
*
* @type {Array<function(decoding.Decoder):AbstractContent>}
*/
export const contentRefs = [
() => { throw error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted,
readContentJSON,
readContentBinary,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
readContentAny
]
/**
* Do not implement this class!
*/
export class AbstractContent {
/**
* @return {number}
*/
getLength () {
throw error.methodUnimplemented()
}
/**
* @return {Array<any>}
*/
getContent () {
throw error.methodUnimplemented()
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
*
* * Whether this Item should be addressable via `yarray.get(i)`
* * Whether this Item should be counted when computing yarray.length
*
* @return {boolean}
*/
isCountable () {
throw error.methodUnimplemented()
}
/**
* @return {AbstractContent}
*/
copy () {
throw error.methodUnimplemented()
}
/**
* @param {number} offset
* @return {AbstractContent}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {AbstractContent} right
* @return {boolean}
*/
mergeWith (right) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
throw error.methodUnimplemented()
}
/**
* @param {StructStore} store
*/
gc (store) {
throw error.methodUnimplemented()
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
throw error.methodUnimplemented()
}
/**
* @return {number}
*/
getRef () {
throw error.methodUnimplemented()
} }
} }
/** /**
* @private * @private
*/ */
export class AbstractItemRef extends AbstractStructRef { export class ItemRef extends AbstractStructRef {
/** /**
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @param {ID} id * @param {ID} id
@@ -553,79 +715,71 @@ export class AbstractItemRef extends AbstractStructRef {
if (this.parent !== null) { if (this.parent !== null) {
missing.push(this.parent) missing.push(this.parent)
} }
/**
* @type {AbstractContent}
*/
this.content = readItemContent(decoder, info)
this.length = this.content.getLength()
} }
}
/**
* @param {AbstractItemRef} item
* @param {number} offset
*
* @function
* @private
*/
export const changeItemRefOffset = (item, offset) => {
item.id = createID(item.id.client, item.id.clock + offset)
item.left = createID(item.id.client, item.id.clock - 1)
}
export class ItemParams {
/** /**
* @param {AbstractItem?} left * @param {Transaction} transaction
* @param {AbstractItem?} right * @param {StructStore} store
* @param {AbstractType<YEvent>?} parent * @param {number} offset
* @param {string|null} parentSub * @return {Item|GC}
*/ */
constructor (left, right, parent, parentSub) { toStruct (transaction, store, offset) {
this.left = left if (offset > 0) {
this.right = right /**
this.parent = parent * @type {ID}
this.parentSub = parentSub */
} const id = this.id
} this.id = createID(id.client, id.clock + offset)
this.left = createID(this.id.client, this.id.clock - 1)
this.content = this.content.splice(offset)
this.length -= offset
}
/** const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
* Outsourcing some of the logic of computing the item params from a received struct. const right = this.right === null ? null : getItemCleanStart(transaction, this.right)
* If parent === null, it is expected to gc the read struct. Otherwise apply it. let parent = null
* let parentSub = this.parentSub
* @param {Transaction} transaction if (this.parent !== null) {
* @param {StructStore} store const parentItem = getItem(store, this.parent)
* @param {ID|null} leftid // Edge case: toStruct is called with an offset > 0. In this case left is defined.
* @param {ID|null} rightid // Depending in which order structs arrive, left may be GC'd and the parent not
* @param {ID|null} parentid // deleted. This is why we check if left is GC'd. Strictly we don't have
* @param {string|null} parentSub // to check if right is GC'd, but we will in case we run into future issues
* @param {string|null} parentYKey if (!parentItem.deleted && (left === null || left.constructor !== GC) && (right === null || right.constructor !== GC)) {
* @return {ItemParams} parent = /** @type {ContentType} */ (parentItem.content).type
* }
* @private } else if (this.parentYKey !== null) {
* @function parent = transaction.doc.get(this.parentYKey)
*/ } else if (left !== null) {
export const computeItemParams = (transaction, store, leftid, rightid, parentid, parentSub, parentYKey) => { if (left.constructor !== GC) {
const left = leftid === null ? null : getItemCleanEnd(transaction, store, leftid) parent = left.parent
const right = rightid === null ? null : getItemCleanStart(transaction, store, rightid) parentSub = left.parentSub
let parent = null }
if (parentid !== null) { } else if (right !== null) {
const parentItem = getItemType(store, parentid) if (right.constructor !== GC) {
switch (parentItem.constructor) { parent = right.parent
case ItemDeleted: parentSub = right.parentSub
case GC: }
break } else {
default: throw error.unexpectedCase()
parent = parentItem.type
} }
} else if (parentYKey !== null) {
parent = transaction.y.get(parentYKey) return parent === null
} else if (left !== null) { ? new GC(this.id, this.length)
if (left.constructor !== GC) { : new Item(
parent = left.parent this.id,
parentSub = left.parentSub left,
} this.left,
} else if (right !== null) { right,
if (right.constructor !== GC) { this.right,
parent = right.parent parent,
parentSub = right.parentSub parentSub,
} this.content
} else { )
throw error.unexpectedCase()
} }
return new ItemParams(left, right, parent, parentSub)
} }

View File

@@ -1,98 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
GC,
StructStore, Transaction, AbstractType, ID // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structBinaryRefNumber = 1
/**
* @private
*/
export class ItemBinary extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {ArrayBuffer} content
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
this.content = content
}
getContent () {
return [this.content]
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemBinary(id, left, origin, right, rightOrigin, parent, parentSub, this.content)
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structBinaryRefNumber)
encoding.writePayload(encoder, this.content)
}
}
/**
* @private
*/
export class ItemBinaryRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
/**
* @type {ArrayBuffer}
*/
this.content = decoding.readPayload(decoder)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemBinary|GC}
*/
toStruct (transaction, store, offset) {
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemBinary(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.content
)
}
}

View File

@@ -1,142 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
changeItemRefOffset,
GC,
splitItem,
addToDeleteSet,
Y, StructStore, Transaction, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structDeletedRefNumber = 2
/**
* @private
*/
export class ItemDeleted extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {number} length
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, length) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
this._len = length
this.deleted = true
}
get length () {
return this._len
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemDeleted(id, left, origin, right, rightOrigin, parent, parentSub, this.length)
}
/**
* @param {Transaction} transaction
*/
integrate (transaction) {
super.integrate(transaction)
addToDeleteSet(transaction.deleteSet, this.id, this.length)
}
/**
* @param {Transaction} transaction
* @param {number} diff
*/
splitAt (transaction, diff) {
/**
* @type {ItemDeleted}
*/
// @ts-ignore
const right = splitItem(transaction, this, diff)
right._len -= diff
this._len = diff
return right
}
/**
* @param {ItemDeleted} right
* @return {boolean}
*/
mergeWith (right) {
if (super.mergeWith(right)) {
this._len += right._len
return true
}
return false
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structDeletedRefNumber)
encoding.writeVarUint(encoder, this.length - offset)
}
}
/**
* @private
*/
export class ItemDeletedRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
/**
* @type {number}
*/
this.len = decoding.readVarUint(decoder)
}
get length () {
return this.len
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemDeleted|GC}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
changeItemRefOffset(this, offset)
this.len = this.len - offset
}
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemDeleted(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.len
)
}
}

View File

@@ -1,95 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
GC,
Transaction, StructStore, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structEmbedRefNumber = 3
/**
* @private
*/
export class ItemEmbed extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {Object} embed
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, embed) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
this.embed = embed
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemEmbed(id, left, origin, right, rightOrigin, parent, parentSub, this.embed)
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structEmbedRefNumber)
encoding.writeVarString(encoder, JSON.stringify(this.embed))
}
}
/**
* @private
*/
export class ItemEmbedRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
/**
* @type {ArrayBuffer}
*/
this.embed = JSON.parse(decoding.readVarString(decoder))
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemEmbed|GC}
*/
toStruct (transaction, store, offset) {
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemEmbed(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.embed
)
}
}

View File

@@ -1,103 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
GC,
Transaction, StructStore, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structFormatRefNumber = 4
/**
* @private
*/
export class ItemFormat extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {string} key
* @param {any} value
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, key, value) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
this.key = key
this.value = value
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemFormat(id, left, origin, right, rightOrigin, parent, parentSub, this.key, this.value)
}
get countable () {
return false
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structFormatRefNumber)
encoding.writeVarString(encoder, this.key)
encoding.writeVarString(encoder, JSON.stringify(this.value))
}
}
/**
* @private
*/
export class ItemFormatRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
/**
* @type {string}
*/
this.key = decoding.readVarString(decoder)
this.value = JSON.parse(decoding.readVarString(decoder))
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemFormat|GC}
*/
toStruct (transaction, store, offset) {
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemFormat(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.key,
this.value
)
}
}

View File

@@ -1,152 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
splitItem,
changeItemRefOffset,
GC,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structJSONRefNumber = 5
/**
* @private
*/
export class ItemJSON extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {Array<any>} content
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
/**
* @type {Array<any>}
*/
this.content = content
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemJSON(id, left, origin, right, rightOrigin, parent, parentSub, this.content)
}
get length () {
return this.content.length
}
getContent () {
return this.content
}
/**
* @param {Transaction} transaction
* @param {number} diff
*/
splitAt (transaction, diff) {
/**
* @type {ItemJSON}
*/
// @ts-ignore
const right = splitItem(transaction, this, diff)
right.content = this.content.splice(diff)
return right
}
/**
* @param {ItemJSON} right
* @return {boolean}
*/
mergeWith (right) {
if (super.mergeWith(right)) {
this.content = this.content.concat(right.content)
return true
}
return false
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structJSONRefNumber)
const len = this.content.length
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.content[i]
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
}
}
}
/**
* @private
*/
export class ItemJSONRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
const c = decoding.readVarString(decoder)
if (c === 'undefined') {
cs.push(undefined)
} else {
cs.push(JSON.parse(c))
}
}
/**
* @type {Array<any>}
*/
this.content = cs
}
get length () {
return this.content.length
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemJSON|GC}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
changeItemRefOffset(this, offset)
this.content = this.content.slice(offset)
}
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemJSON(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.content
)
}
}

View File

@@ -1,137 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
splitItem,
changeItemRefOffset,
GC,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export const structStringRefNumber = 6
/**
* @private
*/
export class ItemString extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {string} string
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, string) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
/**
* @type {string}
*/
this.string = string
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemString(id, left, origin, right, rightOrigin, parent, parentSub, this.string)
}
getContent () {
return this.string.split('')
}
get length () {
return this.string.length
}
/**
* @param {Transaction} transaction
* @param {number} diff
* @return {ItemString}
*/
splitAt (transaction, diff) {
/**
* @type {ItemString}
*/
// @ts-ignore
const right = splitItem(transaction, this, diff)
right.string = this.string.slice(diff)
this.string = this.string.slice(0, diff)
return right
}
/**
* @param {ItemString} right
* @return {boolean}
*/
mergeWith (right) {
if (super.mergeWith(right)) {
this.string += right.string
return true
}
return false
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structStringRefNumber)
encoding.writeVarString(encoder, offset === 0 ? this.string : this.string.slice(offset))
}
}
/**
* @private
*/
export class ItemStringRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
/**
* @type {string}
*/
this.string = decoding.readVarString(decoder)
}
get length () {
return this.string.length
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemString|GC}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
changeItemRefOffset(this, offset)
this.string = this.string.slice(offset)
}
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemString(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.string
)
}
}

View File

@@ -1,178 +0,0 @@
import {
AbstractItem,
AbstractItemRef,
computeItemParams,
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText,
StructStore, Y, GC, Transaction, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export const structTypeRefNumber = 7
/**
* @type {Array<function(decoding.Decoder):AbstractType<any>>}
* @private
*/
export const typeRefs = [
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText
]
export const YArrayRefID = 0
export const YMapRefID = 1
export const YTextRefID = 2
export const YXmlElementRefID = 3
export const YXmlFragmentRefID = 4
export const YXmlHookRefID = 5
export const YXmlTextRefID = 6
/**
* @private
*/
export class ItemType extends AbstractItem {
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {AbstractType<any>} type
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, type) {
super(id, left, origin, right, rightOrigin, parent, parentSub)
this.type = type
}
getContent () {
return [this.type]
}
/**
* @param {ID} id
* @param {AbstractItem | null} left
* @param {ID | null} origin
* @param {AbstractItem | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @return {ItemType}
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
return new ItemType(id, left, origin, right, rightOrigin, parent, parentSub, this.type._copy())
}
/**
* @param {Transaction} transaction
*/
integrate (transaction) {
super.integrate(transaction)
this.type._integrate(transaction.y, this)
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
super.write(encoder, offset, structTypeRefNumber)
this.type._write(encoder)
}
/**
* Mark this Item as deleted.
*
* @param {Transaction} transaction The Yjs instance
* @private
*/
delete (transaction) {
super.delete(transaction)
transaction.changed.delete(this.type)
transaction.changedParentTypes.delete(this.type)
this.gcChildren(transaction, transaction.y.store)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
*/
gcChildren (transaction, store) {
let item = this.type._start
while (item !== null) {
item.gc(transaction, store)
item = item.right
}
this.type._start = null
this.type._map.forEach(item => {
while (item !== null) {
item.gc(transaction, store)
// @ts-ignore
item = item.left
}
})
this._map = new Map()
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
*/
gc (transaction, store) {
super.gc(transaction, store)
this.gcChildren(transaction, store)
}
}
/**
* @private
*/
export class ItemTypeRef extends AbstractItemRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(decoder, id, info)
const typeRef = decoding.readVarUint(decoder)
/**
* @type {AbstractType<any>}
*/
this.type = typeRefs[typeRef](decoder)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {ItemType|GC}
*/
toStruct (transaction, store, offset) {
const { left, right, parent, parentSub } = computeItemParams(transaction, store, this.left, this.right, this.parent, this.parentSub, this.parentYKey)
return parent === null
? new GC(this.id, this.length)
: new ItemType(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.type
)
}
}

View File

@@ -4,14 +4,14 @@ import {
callEventHandlerListeners, callEventHandlerListeners,
addEventHandlerListener, addEventHandlerListener,
createEventHandler, createEventHandler,
ItemType,
nextID, nextID,
isVisible, isVisible,
ItemJSON, ContentType,
ItemBinary, ContentAny,
ContentBinary,
createID, createID,
getItemCleanStart, getItemCleanStart,
Y, Snapshot, Transaction, EventHandler, YEvent, AbstractItem, // eslint-disable-line 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'
@@ -30,7 +30,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
* @param {EventType} event * @param {EventType} event
*/ */
export const callTypeObservers = (type, transaction, event) => { export const callTypeObservers = (type, transaction, event) => {
callEventHandlerListeners(type._eH, event, transaction) const changedType = type
const changedParentTypes = transaction.changedParentTypes const changedParentTypes = transaction.changedParentTypes
while (true) { while (true) {
// @ts-ignore // @ts-ignore
@@ -40,6 +40,7 @@ export const callTypeObservers = (type, transaction, event) => {
} }
type = type._item.parent type = type._item.parent
} }
callEventHandlerListeners(changedType._eH, event, transaction)
} }
/** /**
@@ -49,24 +50,24 @@ export const callTypeObservers = (type, transaction, event) => {
export class AbstractType { export class AbstractType {
constructor () { constructor () {
/** /**
* @type {ItemType|null} * @type {Item|null}
*/ */
this._item = null this._item = null
/** /**
* @private * @private
* @type {Map<string,AbstractItem>} * @type {Map<string,Item>}
*/ */
this._map = new Map() this._map = new Map()
/** /**
* @private * @private
* @type {AbstractItem|null} * @type {Item|null}
*/ */
this._start = null this._start = null
/** /**
* @private * @private
* @type {Y|null} * @type {Doc|null}
*/ */
this._y = null this.doc = null
this._length = 0 this._length = 0
/** /**
* Event handlers * Event handlers
@@ -87,12 +88,12 @@ export class AbstractType {
* * This type is sent to other client * * This type is sent to other client
* * Observer functions are fired * * Observer functions are fired
* *
* @param {Y} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {ItemType|null} item * @param {Item|null} item
* @private * @private
*/ */
_integrate (y, item) { _integrate (y, item) {
this._y = y this.doc = y
this._item = item this._item = item
} }
@@ -101,7 +102,7 @@ export class AbstractType {
* @private * @private
*/ */
_copy () { _copy () {
throw new Error('unimplemented') throw error.methodUnimplemented()
} }
/** /**
@@ -170,7 +171,7 @@ export class AbstractType {
/** /**
* @abstract * @abstract
* @return {Object | Array | number | string} * @return {any}
*/ */
toJSON () {} toJSON () {}
} }
@@ -182,12 +183,35 @@ export class AbstractType {
* @private * @private
* @function * @function
*/ */
export const typeArrayToArray = type => { export const typeListToArray = type => {
const cs = [] const cs = []
let n = type._start let n = type._start
while (n !== null) { while (n !== null) {
if (n.countable && !n.deleted) { if (n.countable && !n.deleted) {
const c = n.getContent() const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* @param {AbstractType<any>} type
* @param {Snapshot} snapshot
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) { for (let i = 0; i < c.length; i++) {
cs.push(c[i]) cs.push(c[i])
} }
@@ -201,17 +225,17 @@ export const typeArrayToArray = type => {
* Executes a provided function on once on overy element of this YArray. * Executes a provided function on once on overy element of this YArray.
* *
* @param {AbstractType<any>} type * @param {AbstractType<any>} type
* @param {function(any,number,AbstractType<any>):void} f A function to execute on every element of this YArray. * @param {function(any,number,any):void} f A function to execute on every element of this YArray.
* *
* @private * @private
* @function * @function
*/ */
export const typeArrayForEach = (type, f) => { export const typeListForEach = (type, f) => {
let index = 0 let index = 0
let n = type._start let n = type._start
while (n !== null) { while (n !== null) {
if (n.countable && !n.deleted) { if (n.countable && !n.deleted) {
const c = n.getContent() const c = n.content.getContent()
for (let i = 0; i < c.length; i++) { for (let i = 0; i < c.length; i++) {
f(c[i], index++, type) f(c[i], index++, type)
} }
@@ -229,12 +253,12 @@ export const typeArrayForEach = (type, f) => {
* @private * @private
* @function * @function
*/ */
export const typeArrayMap = (type, f) => { export const typeListMap = (type, f) => {
/** /**
* @type {Array<any>} * @type {Array<any>}
*/ */
const result = [] const result = []
typeArrayForEach(type, (c, i) => { typeListForEach(type, (c, i) => {
result.push(f(c, i, type)) result.push(f(c, i, type))
}) })
return result return result
@@ -247,7 +271,7 @@ export const typeArrayMap = (type, f) => {
* @private * @private
* @function * @function
*/ */
export const typeArrayCreateIterator = type => { export const typeListCreateIterator = type => {
let n = type._start let n = type._start
/** /**
* @type {Array<any>|null} * @type {Array<any>|null}
@@ -264,18 +288,15 @@ export const typeArrayCreateIterator = type => {
while (n !== null && n.deleted) { while (n !== null && n.deleted) {
n = n.right n = n.right
} }
} // check if we reached the end, no need to check currentContent, because it does not exist
// check if we reached the end, no need to check currentContent, because it does not exist if (n === null) {
if (n === null) { return {
return { done: true,
done: true, value: undefined
value: undefined }
} }
}
// currentContent could exist from the last iteration
if (currentContent === null) {
// we found n, so we can set currentContent // we found n, so we can set currentContent
currentContent = n.getContent() currentContent = n.content.getContent()
currentContentIndex = 0 currentContentIndex = 0
n = n.right // we used the content of n, now iterate to next n = n.right // we used the content of n, now iterate to next
} }
@@ -303,12 +324,12 @@ export const typeArrayCreateIterator = type => {
* @private * @private
* @function * @function
*/ */
export const typeArrayForEachSnapshot = (type, f, snapshot) => { export const typeListForEachSnapshot = (type, f, snapshot) => {
let index = 0 let index = 0
let n = type._start let n = type._start
while (n !== null) { while (n !== null) {
if (n.countable && isVisible(n, snapshot)) { if (n.countable && isVisible(n, snapshot)) {
const c = n.getContent() const c = n.content.getContent()
for (let i = 0; i < c.length; i++) { for (let i = 0; i < c.length; i++) {
f(c[i], index++, type) f(c[i], index++, type)
} }
@@ -325,11 +346,11 @@ export const typeArrayForEachSnapshot = (type, f, snapshot) => {
* @private * @private
* @function * @function
*/ */
export const typeArrayGet = (type, index) => { export const typeListGet = (type, index) => {
for (let n = type._start; n !== null; n = n.right) { for (let n = type._start; n !== null; n = n.right) {
if (!n.deleted && n.countable) { if (!n.deleted && n.countable) {
if (index < n.length) { if (index < n.length) {
return n.getContent()[index] return n.content.getContent()[index]
} }
index -= n.length index -= n.length
} }
@@ -339,22 +360,22 @@ export const typeArrayGet = (type, index) => {
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractItem?} referenceItem * @param {Item?} referenceItem
* @param {Array<Object<string,any>|Array<any>|number|string|ArrayBuffer>} content * @param {Array<Object<string,any>|Array<any>|boolean|number|string|Uint8Array>} content
* *
* @private * @private
* @function * @function
*/ */
export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem, content) => { export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
let left = referenceItem let left = referenceItem
const right = referenceItem === null ? parent._start : referenceItem.right const right = referenceItem === null ? parent._start : referenceItem.right
/** /**
* @type {Array<Object|Array|number>} * @type {Array<Object|Array<any>|number>}
*/ */
let jsonContent = [] let jsonContent = []
const packJsonContent = () => { const packJsonContent = () => {
if (jsonContent.length > 0) { if (jsonContent.length > 0) {
left = new ItemJSON(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, jsonContent) left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction) left.integrate(transaction)
jsonContent = [] jsonContent = []
} }
@@ -363,6 +384,7 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
switch (c.constructor) { switch (c.constructor) {
case Number: case Number:
case Object: case Object:
case Boolean:
case Array: case Array:
case String: case String:
jsonContent.push(c) jsonContent.push(c)
@@ -370,15 +392,14 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
default: default:
packJsonContent() packJsonContent()
switch (c.constructor) { switch (c.constructor) {
case Uint8Array:
case ArrayBuffer: case ArrayBuffer:
// @ts-ignore c is definitely an 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 ItemBinary(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, c)
// @ts-ignore
left.integrate(transaction) left.integrate(transaction)
break break
default: default:
if (c instanceof AbstractType) { if (c instanceof AbstractType) {
left = new ItemType(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, c) left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentType(c))
left.integrate(transaction) left.integrate(transaction)
} else { } else {
throw new Error('Unexpected content type in insert operation') throw new Error('Unexpected content type in insert operation')
@@ -393,14 +414,14 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {number} index * @param {number} index
* @param {Array<Object<string,any>|Array<any>|number|string|ArrayBuffer>} content * @param {Array<Object<string,any>|Array<any>|number|string|Uint8Array>} content
* *
* @private * @private
* @function * @function
*/ */
export const typeArrayInsertGenerics = (transaction, parent, index, content) => { export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index === 0) { if (index === 0) {
return typeArrayInsertGenericsAfter(transaction, parent, null, content) return typeListInsertGenericsAfter(transaction, parent, null, content)
} }
let n = parent._start let n = parent._start
for (; n !== null; n = n.right) { for (; n !== null; n = n.right) {
@@ -408,14 +429,14 @@ export const typeArrayInsertGenerics = (transaction, parent, index, content) =>
if (index <= n.length) { if (index <= n.length) {
if (index < n.length) { if (index < n.length) {
// insert in-between // insert in-between
getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index)) getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
} }
break break
} }
index -= n.length index -= n.length
} }
} }
return typeArrayInsertGenericsAfter(transaction, parent, n, content) return typeListInsertGenericsAfter(transaction, parent, n, content)
} }
/** /**
@@ -427,17 +448,14 @@ export const typeArrayInsertGenerics = (transaction, parent, index, content) =>
* @private * @private
* @function * @function
*/ */
export const typeArrayDelete = (transaction, parent, index, length) => { export const typeListDelete = (transaction, parent, index, length) => {
if (length === 0) { return } if (length === 0) { return }
let n = parent._start let n = parent._start
// compute the first item to be deleted // compute the first item to be deleted
for (; n !== null; n = n.right) { for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) { if (!n.deleted && n.countable) {
if (index <= n.length) { if (index < n.length) {
if (index < n.length && index > 0) { getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
n = getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index))
}
break
} }
index -= n.length index -= n.length
} }
@@ -446,7 +464,7 @@ export const typeArrayDelete = (transaction, parent, index, length) => {
while (length > 0 && n !== null) { while (length > 0 && n !== null) {
if (!n.deleted) { if (!n.deleted) {
if (length < n.length) { if (length < n.length) {
getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + length)) getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
} }
n.delete(transaction) n.delete(transaction)
length -= n.length length -= n.length
@@ -477,52 +495,55 @@ export const typeMapDelete = (transaction, parent, key) => {
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {string} key * @param {string} key
* @param {Object|number|Array<any>|string|ArrayBuffer|AbstractType<any>} value * @param {Object|number|Array<any>|string|Uint8Array|AbstractType<any>} value
* *
* @private * @private
* @function * @function
*/ */
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
let content
if (value == null) { if (value == null) {
new ItemJSON(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, [value]).integrate(transaction) content = new ContentAny([value])
return } else {
} switch (value.constructor) {
switch (value.constructor) { case Number:
case Number: case Object:
case Object: case Boolean:
case Array: case Array:
case String: case String:
new ItemJSON(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, [value]).integrate(transaction) content = new ContentAny([value])
break break
case ArrayBuffer: case Uint8Array:
new ItemBinary(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, value).integrate(transaction) content = new ContentBinary(/** @type {Uint8Array} */ (value))
break break
default: default:
if (value instanceof AbstractType) { if (value instanceof AbstractType) {
new ItemType(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, value).integrate(transaction) content = new ContentType(value)
} else { } else {
throw new Error('Unexpected content type') throw new Error('Unexpected content type')
} }
}
} }
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction)
} }
/** /**
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {string} key * @param {string} key
* @return {Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined} * @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* *
* @private * @private
* @function * @function
*/ */
export const typeMapGet = (parent, key) => { export const typeMapGet = (parent, key) => {
const val = parent._map.get(key) const val = parent._map.get(key)
return val !== undefined && !val.deleted ? val.getContent()[0] : undefined return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined
} }
/** /**
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @return {Object<string,Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined>} * @return {Object<string,Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
* *
* @private * @private
* @function * @function
@@ -531,10 +552,10 @@ export const typeMapGetAll = (parent) => {
/** /**
* @type {Object<string,any>} * @type {Object<string,any>}
*/ */
let res = {} const res = {}
for (const [key, value] of parent._map) { for (const [key, value] of parent._map) {
if (!value.deleted) { if (!value.deleted) {
res[key] = value.getContent()[value.length - 1] res[key] = value.content.getContent()[value.length - 1]
} }
} }
return res return res
@@ -557,24 +578,24 @@ export const typeMapHas = (parent, key) => {
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {string} key * @param {string} key
* @param {Snapshot} snapshot * @param {Snapshot} snapshot
* @return {Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined} * @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* *
* @private * @private
* @function * @function
*/ */
export const typeMapGetSnapshot = (parent, key, snapshot) => { export const typeMapGetSnapshot = (parent, key, snapshot) => {
let v = parent._map.get(key) || null let v = parent._map.get(key) || null
while (v !== null && (!snapshot.sm.has(v.id.client) || v.id.clock >= (snapshot.sm.get(v.id.client) || 0))) { while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
v = v.left v = v.left
} }
return v !== null && isVisible(v, snapshot) ? v.getContent()[v.length - 1] : undefined return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined
} }
/** /**
* @param {Map<string,AbstractItem>} map * @param {Map<string,Item>} map
* @return {IterableIterator<Array<any>>} * @return {IterableIterator<Array<any>>}
* *
* @private * @private
* @function * @function
*/ */
export const createMapIterator = map => iterator.iteratorFilter(map.entries(), entry => !entry[1].deleted) export const createMapIterator = map => iterator.iteratorFilter(map.entries(), /** @param {any} entry */ entry => !entry[1].deleted)

View File

@@ -5,17 +5,17 @@
import { import {
YEvent, YEvent,
AbstractType, AbstractType,
typeArrayGet, typeListGet,
typeArrayToArray, typeListToArray,
typeArrayForEach, typeListForEach,
typeArrayCreateIterator, typeListCreateIterator,
typeArrayInsertGenerics, typeListInsertGenerics,
typeArrayDelete, typeListDelete,
typeArrayMap, typeListMap,
YArrayRefID, YArrayRefID,
callTypeObservers, callTypeObservers,
transact, transact,
Y, Transaction, ItemType, // eslint-disable-line Doc, Transaction, Item // 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
@@ -51,6 +51,7 @@ export class YArray extends AbstractType {
*/ */
this._prelimContent = [] this._prelimContent = []
} }
/** /**
* Integrate this type into the Yjs instance. * Integrate this type into the Yjs instance.
* *
@@ -58,20 +59,25 @@ export class YArray extends AbstractType {
* * This type is sent to other client * * This type is sent to other client
* * Observer functions are fired * * Observer functions are fired
* *
* @param {Y} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {ItemType} item * @param {Item} item
* *
* @private * @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
// @ts-ignore this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this.insert(0, this._prelimContent)
this._prelimContent = null this._prelimContent = null
} }
_copy () {
return new YArray()
}
get length () { get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length return this._prelimContent === null ? this._length : this._prelimContent.length
} }
/** /**
* Creates YArrayEvent and calls observers. * Creates YArrayEvent and calls observers.
* *
@@ -84,6 +90,57 @@ export class YArray extends AbstractType {
callTypeObservers(this, transaction, new YArrayEvent(this, transaction)) callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
} }
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(1, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
/**
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*/
push (content) {
this.insert(this.length, content)
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
}
}
/** /**
* Returns the i-th element from a YArray. * Returns the i-th element from a YArray.
* *
@@ -91,7 +148,7 @@ export class YArray extends AbstractType {
* @return {T} * @return {T}
*/ */
get (index) { get (index) {
return typeArrayGet(this, index) return typeListGet(this, index)
} }
/** /**
@@ -100,7 +157,7 @@ export class YArray extends AbstractType {
* @return {Array<T>} * @return {Array<T>}
*/ */
toArray () { toArray () {
return typeArrayToArray(this) return typeListToArray(this)
} }
/** /**
@@ -122,77 +179,23 @@ export class YArray extends AbstractType {
* callback function * callback function
*/ */
map (f) { map (f) {
// @ts-ignore return typeListMap(this, /** @type {any} */ (f))
return typeArrayMap(this, f)
} }
/** /**
* Executes a provided function on once on overy element of this YArray. * Executes a provided function on once on overy element of this YArray.
* *
* @param {function(T,number):void} f A function to execute on every element of this YArray. * @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/ */
forEach (f) { forEach (f) {
typeArrayForEach(this, f) typeListForEach(this, f)
} }
/** /**
* @return {IterableIterator<T>} * @return {IterableIterator<T>}
*/ */
[Symbol.iterator] () { [Symbol.iterator] () {
return typeArrayCreateIterator(this) return typeListCreateIterator(this)
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(1, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*/
push (content) {
this.insert(this.length, content)
} }
/** /**

View File

@@ -14,7 +14,7 @@ import {
YMapRefID, YMapRefID,
callTypeObservers, callTypeObservers,
transact, transact,
Y, Transaction, ItemType, // eslint-disable-line Doc, Transaction, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
@@ -38,7 +38,7 @@ export class YMapEvent extends YEvent {
} }
/** /**
* @template T number|string|Object|Array|ArrayBuffer * @template T number|string|Object|Array|Uint8Array
* A shared Map implementation. * A shared Map implementation.
* *
* @extends AbstractType<YMapEvent<T>> * @extends AbstractType<YMapEvent<T>>
@@ -53,6 +53,7 @@ export class YMap extends AbstractType {
*/ */
this._prelimContent = new Map() this._prelimContent = new Map()
} }
/** /**
* Integrate this type into the Yjs instance. * Integrate this type into the Yjs instance.
* *
@@ -60,19 +61,23 @@ export class YMap extends AbstractType {
* * This type is sent to other client * * This type is sent to other client
* * Observer functions are fired * * Observer functions are fired
* *
* @param {Y} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {ItemType} item * @param {Item} item
* *
* @private * @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
// @ts-ignore for (const [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
for (let [key, value] of this._prelimContent) {
this.set(key, value) this.set(key, value)
} }
this._prelimContent = null this._prelimContent = null
} }
_copy () {
return new YMap()
}
/** /**
* Creates YMapEvent and calls observers. * Creates YMapEvent and calls observers.
* *
@@ -95,9 +100,9 @@ export class YMap extends AbstractType {
* @type {Object<string,T>} * @type {Object<string,T>}
*/ */
const map = {} const map = {}
for (let [key, item] of this._map) { for (const [key, item] of this._map) {
if (!item.deleted) { if (!item.deleted) {
const v = item.getContent()[0] const v = item.content.getContent()[item.length - 1]
map[key] = v instanceof AbstractType ? v.toJSON() : v map[key] = v instanceof AbstractType ? v.toJSON() : v
} }
} }
@@ -107,18 +112,46 @@ export class YMap extends AbstractType {
/** /**
* Returns the keys for each element in the YMap Type. * Returns the keys for each element in the YMap Type.
* *
* @return {Iterator<string>} * @return {IterableIterator<string>}
*/ */
keys () { keys () {
return iterator.iteratorMap(createMapIterator(this._map), v => v[0]) return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0])
} }
/** /**
* Returns the value for each element in the YMap Type. * Returns the keys for each element in the YMap Type.
* *
* @return {IterableIterator<T>} * @return {IterableIterator<string>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
}
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<any>}
*/ */
entries () { entries () {
return iterator.iteratorMap(createMapIterator(this._map), v => v[1].getContent()[0]) return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => [v[0], v[1].content.getContent()[v[1].length - 1]])
}
/**
* Executes a provided function on once on overy key-value pair.
*
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
/**
* @type {Object<string,T>}
*/
const map = {}
for (const [key, item] of this._map) {
if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this)
}
}
return map
} }
/** /**
@@ -134,13 +167,12 @@ export class YMap extends AbstractType {
* @param {string} key The key of the element to remove. * @param {string} key The key of the element to remove.
*/ */
delete (key) { delete (key) {
if (this._y !== null) { if (this.doc !== null) {
transact(this._y, transaction => { transact(this.doc, transaction => {
typeMapDelete(transaction, this, key) typeMapDelete(transaction, this, key)
}) })
} else { } else {
// @ts-ignore /** @type {Map<string, any>} */ (this._prelimContent).delete(key)
this._prelimContent.delete(key)
} }
} }
@@ -151,13 +183,12 @@ export class YMap extends AbstractType {
* @param {T} value The value of the element to add * @param {T} value The value of the element to add
*/ */
set (key, value) { set (key, value) {
if (this._y !== null) { if (this.doc !== null) {
transact(this._y, transaction => { transact(this.doc, transaction => {
typeMapSet(transaction, this, key, value) typeMapSet(transaction, this, key, value)
}) })
} else { } else {
// @ts-ignore /** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
this._prelimContent.set(key, value)
} }
return value return value
} }
@@ -169,8 +200,7 @@ export class YMap extends AbstractType {
* @return {T|undefined} * @return {T|undefined}
*/ */
get (key) { get (key) {
// @ts-ignore return /** @type {any} */ (typeMapGet(this, key))
return typeMapGet(this, key)
} }
/** /**

View File

@@ -5,9 +5,6 @@
import { import {
YEvent, YEvent,
ItemEmbed,
ItemString,
ItemFormat,
AbstractType, AbstractType,
nextID, nextID,
createID, createID,
@@ -16,16 +13,28 @@ import {
YTextRefID, YTextRefID,
callTypeObservers, callTypeObservers,
transact, transact,
Y, ItemType, AbstractItem, Snapshot, StructStore, Transaction // eslint-disable-line ContentEmbed,
ContentFormat,
ContentString,
splitSnapshotAffectedStructs,
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'
/**
* @param {any} a
* @param {any} b
* @return {boolean}
*/
const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
export class ItemListPosition { export class ItemListPosition {
/** /**
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
*/ */
constructor (left, right) { constructor (left, right) {
this.left = left this.left = left
@@ -35,8 +44,8 @@ export class ItemListPosition {
export class ItemTextListPosition extends ItemListPosition { export class ItemTextListPosition extends ItemListPosition {
/** /**
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
*/ */
constructor (left, right, currentAttributes) { constructor (left, right, currentAttributes) {
@@ -47,8 +56,8 @@ export class ItemTextListPosition extends ItemListPosition {
export class ItemInsertionResult extends ItemListPosition { export class ItemInsertionResult extends ItemListPosition {
/** /**
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} negatedAttributes * @param {Map<string,any>} negatedAttributes
*/ */
constructor (left, right, negatedAttributes) { constructor (left, right, negatedAttributes) {
@@ -59,33 +68,31 @@ export class ItemInsertionResult extends ItemListPosition {
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {number} count * @param {number} count
* @return {ItemTextListPosition} * @return {ItemTextListPosition}
* *
* @private * @private
* @function * @function
*/ */
const findNextPosition = (transaction, store, currentAttributes, left, right, count) => { const findNextPosition = (transaction, currentAttributes, left, right, count) => {
while (right !== null && count > 0) { while (right !== null && count > 0) {
switch (right.constructor) { switch (right.content.constructor) {
case ItemEmbed: case ContentEmbed:
case ItemString: case ContentString:
if (!right.deleted) { if (!right.deleted) {
if (count < right.length) { if (count < right.length) {
// split right // split right
getItemCleanStart(transaction, store, createID(right.id.client, right.id.clock + count)) getItemCleanStart(transaction, createID(right.id.client, right.id.clock + count))
} }
count -= right.length count -= right.length
} }
break break
case ItemFormat: case ContentFormat:
if (!right.deleted) { if (!right.deleted) {
// @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
updateCurrentAttributes(currentAttributes, right)
} }
break break
} }
@@ -97,7 +104,6 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {number} index * @param {number} index
* @return {ItemTextListPosition} * @return {ItemTextListPosition}
@@ -105,11 +111,10 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
* @private * @private
* @function * @function
*/ */
const findPosition = (transaction, store, parent, index) => { const findPosition = (transaction, parent, index) => {
let currentAttributes = new Map() const currentAttributes = new Map()
let left = null const right = parent._start
let right = parent._start return findNextPosition(transaction, currentAttributes, null, right, index)
return findNextPosition(transaction, store, currentAttributes, left, right, index)
} }
/** /**
@@ -117,8 +122,8 @@ const findPosition = (transaction, store, parent, index) => {
* *
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} negatedAttributes * @param {Map<string,any>} negatedAttributes
* @return {ItemListPosition} * @return {ItemListPosition}
* *
@@ -130,36 +135,33 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
while ( while (
right !== null && ( right !== null && (
right.deleted === true || ( right.deleted === true || (
right.constructor === ItemFormat && right.content.constructor === ContentFormat &&
// @ts-ignore right is ItemFormat equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
(negatedAttributes.get(right.key) === right.value)
) )
) )
) { ) {
if (!right.deleted) { if (!right.deleted) {
// @ts-ignore right is ItemFormat negatedAttributes.delete(/** @type {ContentFormat} */ (right.content).key)
negatedAttributes.delete(right.key)
} }
left = right left = right
right = right.right right = right.right
} }
for (let [key, val] of negatedAttributes) { for (const [key, val] of negatedAttributes) {
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val) left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction) left.integrate(transaction)
} }
return {left, right} return { left, right }
} }
/** /**
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {ItemFormat} item * @param {ContentFormat} format
* *
* @private * @private
* @function * @function
*/ */
const updateCurrentAttributes = (currentAttributes, item) => { const updateCurrentAttributes = (currentAttributes, format) => {
const value = item.value const { key, value } = format
const key = item.key
if (value === null) { if (value === null) {
currentAttributes.delete(key) currentAttributes.delete(key)
} else { } else {
@@ -168,8 +170,8 @@ const updateCurrentAttributes = (currentAttributes, item) => {
} }
/** /**
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes * @param {Object<string,any>} attributes
* @return {ItemListPosition} * @return {ItemListPosition}
@@ -184,11 +186,9 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
break break
} else if (right.deleted) { } else if (right.deleted) {
// continue // continue
// @ts-ignore right is ItemFormat } else if (right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (right.content)).key] || null, /** @type {ContentFormat} */ (right.content).value)) {
} else if (right.constructor === ItemFormat && (attributes[right.key] || null) === right.value) {
// found a format, update currentAttributes and continue // found a format, update currentAttributes and continue
// @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
updateCurrentAttributes(currentAttributes, right)
} else { } else {
break break
} }
@@ -201,8 +201,8 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes * @param {Object<string,any>} attributes
* @return {ItemInsertionResult} * @return {ItemInsertionResult}
@@ -213,13 +213,13 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => { const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => {
const negatedAttributes = new Map() const negatedAttributes = new Map()
// insert format-start items // insert format-start items
for (let key in attributes) { for (const key in attributes) {
const val = attributes[key] const val = attributes[key]
const currentVal = currentAttributes.get(key) const currentVal = currentAttributes.get(key) || null
if (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 || null) negatedAttributes.set(key, currentVal)
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val) left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction) left.integrate(transaction)
} }
} }
@@ -229,10 +229,10 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {string} text * @param {string|object} text
* @param {Object<string,any>} attributes * @param {Object<string,any>} attributes
* @return {ItemListPosition} * @return {ItemListPosition}
* *
@@ -240,7 +240,7 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
* @function * @function
**/ **/
const insertText = (transaction, parent, left, right, currentAttributes, text, attributes) => { const insertText = (transaction, parent, left, right, currentAttributes, text, attributes) => {
for (let [key] of currentAttributes) { for (const [key] of currentAttributes) {
if (attributes[key] === undefined) { if (attributes[key] === undefined) {
attributes[key] = null attributes[key] = null
} }
@@ -250,11 +250,8 @@ const insertText = (transaction, parent, left, right, currentAttributes, text, a
left = insertPos.left left = insertPos.left
right = insertPos.right right = insertPos.right
// insert content // insert content
if (text.constructor === String) { const content = text.constructor === String ? new ContentString(text) : new ContentEmbed(text)
left = new ItemString(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, text) left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, content)
} else {
left = new ItemEmbed(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, text)
}
left.integrate(transaction) left.integrate(transaction)
return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes) return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes)
} }
@@ -262,8 +259,8 @@ const insertText = (transaction, parent, left, right, currentAttributes, text, a
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {number} length * @param {number} length
* @param {Object<string,any>} attributes * @param {Object<string,any>} attributes
@@ -281,29 +278,26 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
// iterate until first non-format or null is found // iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null // delete all formats with attributes[format.key] != null
while (length > 0 && right !== null) { while (length > 0 && right !== null) {
if (right.deleted === false) { if (!right.deleted) {
switch (right.constructor) { switch (right.content.constructor) {
case ItemFormat: case ContentFormat: {
// @ts-ignore right is ItemFormat const { key, value } = /** @type {ContentFormat} */ (right.content)
const attr = attributes[right.key] const attr = attributes[key]
if (attr !== undefined) { if (attr !== undefined) {
// @ts-ignore right is ItemFormat if (equalAttrs(attr, value)) {
if (attr === right.value) { negatedAttributes.delete(key)
// @ts-ignore right is ItemFormat
negatedAttributes.delete(right.key)
} else { } else {
// @ts-ignore right is ItemFormat negatedAttributes.set(key, value)
negatedAttributes.set(right.key, right.value)
} }
right.delete(transaction) right.delete(transaction)
} }
// @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
updateCurrentAttributes(currentAttributes, right)
break break
case ItemEmbed: }
case ItemString: case ContentEmbed:
case ContentString:
if (length < right.length) { if (length < right.length) {
getItemCleanStart(transaction, transaction.y.store, createID(right.id.client, right.id.clock + length)) getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
} }
length -= right.length length -= right.length
break break
@@ -312,13 +306,24 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
left = right left = right
right = right.right right = right.right
} }
// Quill just assumes that the editor starts with a newline and that it always
// ends with a newline. We only insert that newline when a new newline is
// inserted - i.e when length is bigger than type.length
if (length > 0) {
let newlines = ''
for (; length > 0; length--) {
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.integrate(transaction)
}
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes) return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractItem|null} left * @param {Item|null} left
* @param {AbstractItem|null} right * @param {Item|null} right
* @param {Map<string,any>} currentAttributes * @param {Map<string,any>} currentAttributes
* @param {number} length * @param {number} length
* @return {ItemListPosition} * @return {ItemListPosition}
@@ -329,15 +334,14 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
const deleteText = (transaction, left, right, currentAttributes, length) => { const deleteText = (transaction, left, right, currentAttributes, length) => {
while (length > 0 && right !== null) { while (length > 0 && right !== null) {
if (right.deleted === false) { if (right.deleted === false) {
switch (right.constructor) { switch (right.content.constructor) {
case ItemFormat: case ContentFormat:
// @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
updateCurrentAttributes(currentAttributes, right)
break break
case ItemEmbed: case ContentEmbed:
case ItemString: case ContentString:
if (length < right.length) { if (length < right.length) {
getItemCleanStart(transaction, transaction.y.store, createID(right.id.client, right.id.clock + length)) getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
} }
length -= right.length length -= right.length
right.delete(transaction) right.delete(transaction)
@@ -388,7 +392,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
/** /**
* Event that describes the changes on a YText type. * Event that describes the changes on a YText type.
*/ */
class YTextEvent extends YEvent { export class YTextEvent extends YEvent {
/** /**
* @param {YText} ytext * @param {YText} ytext
* @param {Transaction} transaction * @param {Transaction} transaction
@@ -401,6 +405,7 @@ class YTextEvent extends YEvent {
*/ */
this._delta = null this._delta = null
} }
/** /**
* Compute the changes in the delta format. * Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document. * A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
@@ -411,13 +416,10 @@ class YTextEvent extends YEvent {
*/ */
get delta () { get delta () {
if (this._delta === null) { if (this._delta === null) {
const y = this.target._y const y = /** @type {Doc} */ (this.target.doc)
// @ts-ignore this._delta = []
transact(y, transaction => { transact(y, transaction => {
/** const delta = /** @type {Array<DeltaItem>} */ (this._delta)
* @type {Array<DeltaItem>}
*/
const delta = []
const currentAttributes = new Map() // saves all current attributes for insert const currentAttributes = new Map() // saves all current attributes for insert
const oldAttributes = new Map() const oldAttributes = new Map()
let item = this.target._start let item = this.target._start
@@ -428,11 +430,13 @@ class YTextEvent extends YEvent {
/** /**
* @type {Object<string,any>} * @type {Object<string,any>}
*/ */
let attributes = {} // counts added or removed new attributes for retain const attributes = {} // counts added or removed new attributes for retain
/**
* @type {string|object}
*/
let insert = '' let insert = ''
let retain = 0 let retain = 0
let deleteLen = 0 let deleteLen = 0
this._delta = delta
const addOp = () => { const addOp = () => {
if (action !== null) { if (action !== null) {
/** /**
@@ -448,7 +452,7 @@ class YTextEvent extends YEvent {
op = { insert } op = { insert }
if (currentAttributes.size > 0) { if (currentAttributes.size > 0) {
op.attributes = {} op.attributes = {}
for (let [key, value] of currentAttributes) { for (const [key, value] of currentAttributes) {
if (value !== null) { if (value !== null) {
op.attributes[key] = value op.attributes[key] = value
} }
@@ -460,7 +464,7 @@ class YTextEvent extends YEvent {
op = { retain } op = { retain }
if (Object.keys(attributes).length > 0) { if (Object.keys(attributes).length > 0) {
op.attributes = {} op.attributes = {}
for (let key in attributes) { for (const key in attributes) {
op.attributes[key] = attributes[key] op.attributes[key] = attributes[key]
} }
} }
@@ -472,14 +476,15 @@ class YTextEvent extends YEvent {
} }
} }
while (item !== null) { while (item !== null) {
switch (item.constructor) { switch (item.content.constructor) {
case ItemEmbed: case ContentEmbed:
if (this.adds(item)) { if (this.adds(item)) {
addOp() if (!this.deletes(item)) {
action = 'insert' addOp()
// @ts-ignore item is ItemFormat action = 'insert'
insert = item.embed insert = /** @type {ContentEmbed} */ (item.content).embed
addOp() addOp()
}
} else if (this.deletes(item)) { } else if (this.deletes(item)) {
if (action !== 'delete') { if (action !== 'delete') {
addOp() addOp()
@@ -494,14 +499,15 @@ class YTextEvent extends YEvent {
retain += 1 retain += 1
} }
break break
case ItemString: case ContentString:
if (this.adds(item)) { if (this.adds(item)) {
if (action !== 'insert') { if (!this.deletes(item)) {
addOp() if (action !== 'insert') {
action = 'insert' addOp()
action = 'insert'
}
insert += /** @type {ContentString} */ (item.content).str
} }
// @ts-ignore
insert += item.string
} else if (this.deletes(item)) { } else if (this.deletes(item)) {
if (action !== 'delete') { if (action !== 'delete') {
addOp() addOp()
@@ -516,57 +522,45 @@ class YTextEvent extends YEvent {
retain += item.length retain += item.length
} }
break break
case ItemFormat: case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (item.content)
if (this.adds(item)) { if (this.adds(item)) {
// @ts-ignore item is ItemFormat if (!this.deletes(item)) {
const curVal = currentAttributes.get(item.key) || null const curVal = currentAttributes.get(key) || null
// @ts-ignore item is ItemFormat if (!equalAttrs(curVal, value)) {
if (curVal !== item.value) {
if (action === 'retain') {
addOp()
}
// @ts-ignore item is ItemFormat
if (item.value === (oldAttributes.get(item.key) || null)) {
// @ts-ignore item is ItemFormat
delete attributes[item.key]
} else {
// @ts-ignore item is ItemFormat
attributes[item.key] = item.value
}
} else {
item.delete(transaction)
}
} else if (this.deletes(item)) {
// @ts-ignore item is ItemFormat
oldAttributes.set(item.key, item.value)
// @ts-ignore item is ItemFormat
const curVal = currentAttributes.get(item.key) || null
// @ts-ignore item is ItemFormat
if (curVal !== item.value) {
if (action === 'retain') {
addOp()
}
// @ts-ignore item is ItemFormat
attributes[item.key] = curVal
}
} else if (!item.deleted) {
// @ts-ignore item is ItemFormat
oldAttributes.set(item.key, item.value)
// @ts-ignore item is ItemFormat
const attr = attributes[item.key]
if (attr !== undefined) {
// @ts-ignore item is ItemFormat
if (attr !== item.value) {
if (action === 'retain') { if (action === 'retain') {
addOp() addOp()
} }
// @ts-ignore item is ItemFormat if (equalAttrs(value, (oldAttributes.get(key) || null))) {
if (item.value === null) { delete attributes[key]
// @ts-ignore item is ItemFormat
attributes[item.key] = item.value
} else { } else {
// @ts-ignore item is ItemFormat attributes[key] = value
delete attributes[item.key] }
} else {
item.delete(transaction)
}
}
} else if (this.deletes(item)) {
oldAttributes.set(key, value)
const curVal = currentAttributes.get(key) || null
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
attributes[key] = curVal
}
} else if (!item.deleted) {
oldAttributes.set(key, value)
const attr = attributes[key]
if (attr !== undefined) {
if (!equalAttrs(attr, value)) {
if (action === 'retain') {
addOp()
}
if (value === null) {
attributes[key] = value
} else {
delete attributes[key]
} }
} else { } else {
item.delete(transaction) item.delete(transaction)
@@ -577,26 +571,25 @@ class YTextEvent extends YEvent {
if (action === 'insert') { if (action === 'insert') {
addOp() addOp()
} }
// @ts-ignore item is ItemFormat updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (item.content))
updateCurrentAttributes(currentAttributes, item)
} }
break break
}
} }
item = item.right item = item.right
} }
addOp() addOp()
while (this._delta.length > 0) { while (delta.length > 0) {
let lastOp = this._delta[this._delta.length - 1] const lastOp = delta[delta.length - 1]
if (lastOp.retain !== undefined && lastOp.attributes === undefined) { if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
// retain delta's if they don't assign attributes // retain delta's if they don't assign attributes
this._delta.pop() delta.pop()
} else { } else {
break break
} }
} }
}) })
} }
// @ts-ignore _delta is defined above
return this._delta return this._delta
} }
} }
@@ -617,27 +610,40 @@ export class YText extends AbstractType {
constructor (string) { constructor (string) {
super() super()
/** /**
* @type {Array<string>?} * Array of pending operations on this type
* @type {Array<function():void>?}
* @private * @private
*/ */
this._prelimContent = string !== undefined ? [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
} }
/** /**
* @param {Y} y * @param {Doc} y
* @param {ItemType} item * @param {Item} item
* *
* @private * @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
// @ts-ignore this._prelimContent is still defined try {
this.insert(0, this._prelimContent.join('')) /** @type {Array<function>} */ (this._pending).forEach(f => f())
this._prelimContent = null } catch (e) {
console.error(e)
}
this._pending = null
}
_copy () {
return new YText()
} }
/** /**
@@ -652,10 +658,6 @@ export class YText extends AbstractType {
callTypeObservers(this, transaction, new YTextEvent(this, transaction)) callTypeObservers(this, transaction, new YTextEvent(this, transaction))
} }
toDom () {
return document.createTextNode(this.toString())
}
/** /**
* Returns the unformatted string representation of this YText type. * Returns the unformatted string representation of this YText type.
* *
@@ -664,51 +666,26 @@ export class YText extends AbstractType {
toString () { toString () {
let str = '' let str = ''
/** /**
* @type {AbstractItem|null} * @type {Item|null}
*/ */
let n = this._start let n = this._start
while (n !== null) { while (n !== null) {
if (!n.deleted && n.countable && n.constructor === ItemString) { if (!n.deleted && n.countable && n.content.constructor === ContentString) {
// @ts-ignore str += /** @type {ContentString} */ (n.content).str
str += n.string
} }
n = n.right n = n.right
} }
return str return str
} }
toDomString () { /**
// @ts-ignore * Returns the unformatted string representation of this YText type.
return this.toDelta().map(delta => { *
const nestedNodes = [] * @return {string}
for (let nodeName in delta.attributes) { * @public
const attrs = [] */
for (let key in delta.attributes[nodeName]) { toJSON () {
attrs.push({ key, value: delta.attributes[nodeName][key] }) return this.toString()
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[i]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
})
} }
/** /**
@@ -719,8 +696,8 @@ export class YText extends AbstractType {
* @public * @public
*/ */
applyDelta (delta) { applyDelta (delta) {
if (this._y !== null) { if (this.doc !== null) {
transact(this._y, transaction => { transact(this.doc, transaction => {
/** /**
* @type {ItemListPosition} * @type {ItemListPosition}
*/ */
@@ -729,7 +706,15 @@ export class YText extends AbstractType {
for (let i = 0; i < delta.length; i++) { for (let i = 0; i < delta.length; i++) {
const op = delta[i] const op = delta[i]
if (op.insert !== undefined) { if (op.insert !== undefined) {
pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, op.insert, op.attributes || {}) // Quill assumes that the content starts with an empty paragraph.
// Yjs/Y.Text assumes that it starts empty. We always hide that
// there is a newline at the end of the content.
// If we omit this step, clients will see a different number of
// 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
if (typeof ins !== 'string' || ins.length > 0) {
pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, ins, op.attributes || {})
}
} else if (op.retain !== undefined) { } else if (op.retain !== undefined) {
pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {}) pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {})
} else if (op.delete !== undefined) { } else if (op.delete !== undefined) {
@@ -737,6 +722,8 @@ export class YText extends AbstractType {
} }
} }
}) })
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.applyDelta(delta))
} }
} }
@@ -745,21 +732,19 @@ export class YText extends AbstractType {
* *
* @param {Snapshot} [snapshot] * @param {Snapshot} [snapshot]
* @param {Snapshot} [prevSnapshot] * @param {Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', ID):any} [computeYChange]
* @return {any} The Delta representation of this type. * @return {any} The Delta representation of this type.
* *
* @public * @public
*/ */
toDelta (snapshot, prevSnapshot) { toDelta (snapshot, prevSnapshot, computeYChange) {
/** /**
* @type{Array<any>} * @type{Array<any>}
*/ */
const ops = [] const ops = []
const currentAttributes = new Map() const currentAttributes = new Map()
const doc = /** @type {Doc} */ (this.doc)
let str = '' let str = ''
/**
* @type {AbstractItem|null}
*/
// @ts-ignore
let n = this._start let n = this._start
function packStr () { function packStr () {
if (str.length > 0) { if (str.length > 0) {
@@ -769,7 +754,7 @@ export class YText extends AbstractType {
*/ */
const attributes = {} const attributes = {}
let addAttributes = false let addAttributes = false
for (let [key, value] of currentAttributes) { for (const [key, value] of currentAttributes) {
addAttributes = true addAttributes = true
attributes[key] = value attributes[key] = value
} }
@@ -784,38 +769,55 @@ export class YText extends AbstractType {
str = '' str = ''
} }
} }
while (n !== null) { // snapshots are merged again after the transaction, so we need to keep the
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) { // transalive until we are done
switch (n.constructor) { transact(doc, transaction => {
case ItemString: if (snapshot) {
const cur = currentAttributes.get('ychange') splitSnapshotAffectedStructs(transaction, snapshot)
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', { user: n.id.client, state: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', { user: n.id.client, state: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
// @ts-ignore
str += n.string
break
case ItemFormat:
packStr()
// @ts-ignore
updateCurrentAttributes(currentAttributes, n)
break
}
} }
n = n.right if (prevSnapshot) {
} splitSnapshotAffectedStructs(transaction, prevSnapshot)
packStr() }
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString: {
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
break
}
case ContentEmbed:
packStr()
ops.push({
insert: /** @type {ContentEmbed} */ (n.content).embed
})
break
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
}
n = n.right
}
packStr()
}, splitSnapshotAffectedStructs)
return ops return ops
} }
@@ -824,21 +826,28 @@ export class YText extends AbstractType {
* *
* @param {number} index The index at which to start inserting. * @param {number} index The index at which to start inserting.
* @param {String} text The text to insert at the specified position. * @param {String} text The text to insert at the specified position.
* @param {TextAttributes} attributes Optionally define some formatting * @param {TextAttributes} [attributes] Optionally define some formatting
* information to apply on the inserted * information to apply on the inserted
* Text. * Text.
* @public * @public
*/ */
insert (index, text, attributes = {}) { insert (index, text, attributes) {
if (text.length <= 0) { if (text.length <= 0) {
return return
} }
const y = this._y const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
const {left, right, currentAttributes} = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (!attributes) {
attributes = {}
// @ts-ignore
currentAttributes.forEach((v, k) => { attributes[k] = v })
}
insertText(transaction, this, left, right, currentAttributes, text, attributes) insertText(transaction, this, left, right, currentAttributes, text, attributes)
}) })
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insert(index, text, attributes))
} }
} }
@@ -856,12 +865,14 @@ export class YText extends AbstractType {
if (embed.constructor !== Object) { if (embed.constructor !== Object) {
throw new Error('Embed must be an Object') throw new Error('Embed must be an Object')
} }
const y = this._y const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, this, index)
insertText(transaction, this, left, right, currentAttributes, embed, attributes) insertText(transaction, this, left, right, currentAttributes, embed, attributes)
}) })
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insertEmbed(index, embed, attributes))
} }
} }
@@ -877,12 +888,14 @@ export class YText extends AbstractType {
if (length === 0) { if (length === 0) {
return return
} }
const y = this._y const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, this, index)
deleteText(transaction, left, right, currentAttributes, length) deleteText(transaction, left, right, currentAttributes, length)
}) })
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.delete(index, length))
} }
} }
@@ -897,15 +910,17 @@ export class YText extends AbstractType {
* @public * @public
*/ */
format (index, length, attributes) { format (index, length, attributes) {
const y = this._y const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (right === null) { if (right === null) {
return return
} }
formatText(transaction, this, left, right, currentAttributes, length, attributes) formatText(transaction, this, left, right, currentAttributes, length, attributes)
}) })
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))
} }
} }

View File

@@ -1,244 +1,19 @@
/**
* @module YXml
*/
import { import {
YXmlEvent, YXmlFragment,
AbstractType, transact,
typeArrayMap, typeMapDelete,
typeArrayForEach, typeMapSet,
typeMapGet, typeMapGet,
typeMapGetAll, typeMapGetAll,
typeArrayInsertGenerics, typeListForEach,
typeArrayDelete,
typeMapSet,
typeMapDelete,
YXmlElementRefID, YXmlElementRefID,
callTypeObservers, Snapshot, Doc, Item // eslint-disable-line
transact,
Y, Transaction, ItemType, YXmlText, YXmlHook, Snapshot // 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 decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*/
/**
* Dom filter function.
*
* @callback domFilter
* @param {string} nodeName The nodeName of the element
* @param {Map} attributes The map of attributes.
* @return {boolean} Whether to include the Dom node in the YXmlElement.
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {IterableIterator}
*/
export class YXmlTreeWalker {
/**
* @param {YXmlFragment | YXmlElement} root
* @param {function(AbstractType<any>):boolean} [f]
*/
constructor (root, f = () => true) {
this._filter = f
this._root = root
/**
* @type {ItemType | null}
*/
// @ts-ignore
this._currentNode = root._start
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
*
* @public
*/
next () {
let n = this._currentNode
if (n !== null && (!this._firstCall || n.deleted || !this._filter(n.type))) { // if first call, we check if we can use the first item
do {
if (!n.deleted && (n.type.constructor === YXmlElement || n.type.constructor === YXmlFragment) && n.type._start !== null) {
// walk down in the tree
// @ts-ignore
n = n.type._start
} else {
// walk right or up in the tree
while (n !== null) {
if (n.right !== null) {
// @ts-ignore
n = n.right
break
} else if (n.parent === this._root) {
n = null
} else {
n = n.parent._item
}
}
}
} while (n !== null && (n.deleted || !this._filter(n.type)))
}
this._firstCall = false
this._currentNode = n
if (n === null) {
// @ts-ignore return undefined if done=true (the expected result)
return { value: undefined, done: true }
}
// @ts-ignore
return { value: n.type, done: false }
}
}
/**
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
* nodeName and it does not have attributes. Though it can be bound to a DOM
* element - in this case the attributes and the nodeName are not shared.
*
* @public
* @extends AbstractType<YXmlEvent>
*/
export class YXmlFragment extends AbstractType {
/**
* Create a subtree of childNodes.
*
* @example
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
* for (let node in walker) {
* // `node` is a div node
* nop(node)
* }
*
* @param {function(AbstractType<any>):boolean} filter Function that is called on each child element and
* returns a Boolean indicating whether the child
* is to be included in the subtree.
* @return {YXmlTreeWalker} A subtree and a position within it.
*
* @public
*/
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Returns the first YXmlElement that matches the query.
* Similar to DOM's {@link querySelector}.
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*
* @param {CSS_Selector} query The query on the children.
* @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
const next = iterator.next()
if (next.done) {
return null
} else {
return next.value
}
}
/**
* Returns all YXmlElements that match the query.
* Similar to Dom's {@link querySelectorAll}.
*
* @todo Does not yet support all queries. Currently only query by tagName.
*
* @param {CSS_Selector} query The query on the children
* @return {Array<YXmlElement|YXmlText|YXmlHook|null>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
/**
* Creates YXmlEvent and calls observers.
* @private
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction))
}
toString () {
return this.toDomString()
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toDomString () {
return typeArrayMap(this, xml => xml.toDomString()).join('')
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
typeArrayForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
})
return fragment
}
}
/** /**
* An YXmlElement imitates the behavior of a * An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}. * {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
@@ -249,12 +24,7 @@ export class YXmlFragment extends AbstractType {
export class YXmlElement extends YXmlFragment { export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') { constructor (nodeName = 'UNDEFINED') {
super() super()
this.nodeName = nodeName.toUpperCase() this.nodeName = nodeName
/**
* @type {Array<any>|null}
* @private
*/
this._prelimContent = []
/** /**
* @type {Map<string, any>|null} * @type {Map<string, any>|null}
* @private * @private
@@ -269,20 +39,16 @@ export class YXmlElement extends YXmlFragment {
* * This type is sent to other client * * This type is sent to other client
* * Observer functions are fired * * Observer functions are fired
* *
* @param {Y} y The Yjs instance * @param {Doc} y The Yjs instance
* @param {ItemType} item * @param {Item} item
* @private * @private
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
// @ts-ignore ;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
this.insert(0, this._prelimContent)
this._prelimContent = null
// @ts-ignore
this._prelimAttrs.forEach((value, key) => {
this.setAttribute(key, value) this.setAttribute(key, value)
}) })
this._prelimContent = null this._prelimAttrs = null
} }
/** /**
@@ -295,24 +61,20 @@ export class YXmlElement extends YXmlFragment {
return new YXmlElement(this.nodeName) return new YXmlElement(this.nodeName)
} }
toString () {
return this.toDomString()
}
/** /**
* Returns the string representation of this YXmlElement. * Returns the XML serialization of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this * The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements * method to compare YXmlElements
* *
* @return {String} The string representation of this type. * @return {string} The string representation of this type.
* *
* @public * @public
*/ */
toDomString () { toString () {
const attrs = this.getAttributes() const attrs = this.getAttributes()
const stringBuilder = [] const stringBuilder = []
const keys = [] const keys = []
for (let key in attrs) { for (const key in attrs) {
keys.push(key) keys.push(key)
} }
keys.sort() keys.sort()
@@ -323,7 +85,7 @@ export class YXmlElement extends YXmlFragment {
} }
const nodeName = this.nodeName.toLocaleLowerCase() const nodeName = this.nodeName.toLocaleLowerCase()
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : '' const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
return `<${nodeName}${attrsString}>${super.toDomString()}</${nodeName}>` return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
} }
/** /**
@@ -334,13 +96,12 @@ export class YXmlElement extends YXmlFragment {
* @public * @public
*/ */
removeAttribute (attributeName) { removeAttribute (attributeName) {
if (this._y !== null) { if (this.doc !== null) {
transact(this._y, transaction => { transact(this.doc, transaction => {
typeMapDelete(transaction, this, attributeName) typeMapDelete(transaction, this, attributeName)
}) })
} else { } else {
// @ts-ignore /** @type {Map<string,any>} */ (this._prelimAttrs).delete(attributeName)
this._prelimAttrs.delete(attributeName)
} }
} }
@@ -353,13 +114,12 @@ export class YXmlElement extends YXmlFragment {
* @public * @public
*/ */
setAttribute (attributeName, attributeValue) { setAttribute (attributeName, attributeValue) {
if (this._y !== null) { if (this.doc !== null) {
transact(this._y, transaction => { transact(this.doc, transaction => {
typeMapSet(transaction, this, attributeName, attributeValue) typeMapSet(transaction, this, attributeName, attributeValue)
}) })
} else { } else {
// @ts-ignore /** @type {Map<string, any>} */ (this._prelimAttrs).set(attributeName, attributeValue)
this._prelimAttrs.set(attributeName, attributeValue)
} }
} }
@@ -373,15 +133,14 @@ export class YXmlElement extends YXmlFragment {
* @public * @public
*/ */
getAttribute (attributeName) { getAttribute (attributeName) {
// @ts-ignore return /** @type {any} */ (typeMapGet(this, attributeName))
return typeMapGet(this, attributeName)
} }
/** /**
* Returns all attribute name/value pairs in a JSON Object. * Returns all attribute name/value pairs in a JSON Object.
* *
* @param {Snapshot} [snapshot] * @param {Snapshot} [snapshot]
* @return {Object} A JSON Object that describes the attributes. * @return {Object<string, any>} A JSON Object that describes the attributes.
* *
* @public * @public
*/ */
@@ -389,44 +148,6 @@ export class YXmlElement extends YXmlFragment {
return typeMapGetAll(this) return typeMapGetAll(this)
} }
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {number} index The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insert (index, content) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/** /**
* Creates a Dom Element that mirrors this YXmlElement. * Creates a Dom Element that mirrors this YXmlElement.
* *
@@ -442,14 +163,14 @@ export class YXmlElement extends YXmlFragment {
* *
* @public * @public
*/ */
toDom (_document = document, hooks = {}, binding) { toDOM (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName) const dom = _document.createElement(this.nodeName)
let attrs = this.getAttributes() const attrs = this.getAttributes()
for (let key in attrs) { for (const key in attrs) {
dom.setAttribute(key, attrs[key]) dom.setAttribute(key, attrs[key])
} }
typeArrayForEach(this, yxml => { typeListForEach(this, yxml => {
dom.appendChild(yxml.toDom(_document, hooks, binding)) dom.appendChild(yxml.toDOM(_document, hooks, binding))
}) })
if (binding !== undefined) { if (binding !== undefined) {
binding._createAssociation(dom, this) binding._createAssociation(dom, this)
@@ -480,11 +201,3 @@ export class YXmlElement extends YXmlFragment {
* @function * @function
*/ */
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder)) export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))
/**
* @param {decoding.Decoder} decoder
* @return {YXmlFragment}
*
* @private
* @function
*/
export const readYXmlFragment = decoder => new YXmlFragment()

346
src/types/YXmlFragment.js Normal file
View File

@@ -0,0 +1,346 @@
/**
* @module YXml
*/
import {
YXmlEvent,
YXmlElement,
AbstractType,
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*/
/**
* Dom filter function.
*
* @callback domFilter
* @param {string} nodeName The nodeName of the element
* @param {Map} attributes The map of attributes.
* @return {boolean} Whether to include the Dom node in the YXmlElement.
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {IterableIterator}
*/
export class YXmlTreeWalker {
/**
* @param {YXmlFragment | YXmlElement} root
* @param {function(AbstractType<any>):boolean} [f]
*/
constructor (root, f = () => true) {
this._filter = f
this._root = root
/**
* @type {Item}
*/
this._currentNode = /** @type {Item} */ (root._start)
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
*
* @public
*/
next () {
/**
* @type {Item|null}
*/
let n = this._currentNode
let type = /** @type {ContentType} */ (n.content).type
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
do {
type = /** @type {ContentType} */ (n.content).type
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
// walk down in the tree
n = type._start
} else {
// walk right or up in the tree
while (n !== null) {
if (n.right !== null) {
n = n.right
break
} else if (n.parent === this._root) {
n = null
} else {
n = n.parent._item
}
}
}
} while (n !== null && (n.deleted || !this._filter(/** @type {ContentType} */ (n.content).type)))
}
this._firstCall = false
if (n === null) {
// @ts-ignore
return { value: undefined, done: true }
}
this._currentNode = n
return { value: /** @type {any} */ (n.content).type, done: false }
}
}
/**
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
* nodeName and it does not have attributes. Though it can be bound to a DOM
* element - in this case the attributes and the nodeName are not shared.
*
* @public
* @extends AbstractType<YXmlEvent>
*/
export class YXmlFragment extends AbstractType {
constructor () {
super()
/**
* @type {Array<any>|null}
* @private
*/
this._prelimContent = []
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this._prelimContent = null
}
_copy () {
return new YXmlFragment()
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
/**
* Create a subtree of childNodes.
*
* @example
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
* for (let node in walker) {
* // `node` is a div node
* nop(node)
* }
*
* @param {function(AbstractType<any>):boolean} filter Function that is called on each child element and
* returns a Boolean indicating whether the child
* is to be included in the subtree.
* @return {YXmlTreeWalker} A subtree and a position within it.
*
* @public
*/
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Returns the first YXmlElement that matches the query.
* Similar to DOM's {@link querySelector}.
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*
* @param {CSS_Selector} query The query on the children.
* @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
const iterator = new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query)
const next = iterator.next()
if (next.done) {
return null
} else {
return next.value
}
}
/**
* Returns all YXmlElements that match the query.
* Similar to Dom's {@link querySelectorAll}.
*
* @todo Does not yet support all queries. Currently only query by tagName.
*
* @param {CSS_Selector} query The query on the children
* @return {Array<YXmlElement|YXmlText|YXmlHook|null>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
}
/**
* Creates YXmlEvent and calls observers.
* @private
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction))
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toString () {
return typeListMap(this, xml => xml.toString()).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
typeListForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null)
})
return fragment
}
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {number} index The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array<YXmlElement|YXmlText|YXmlHook>}
*/
toArray () {
return typeListToArray(this)
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoding.writeVarUint(encoder, YXmlFragmentRefID)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YXmlFragment}
*
* @private
* @function
*/
export const readYXmlFragment = decoder => new YXmlFragment()

View File

@@ -47,7 +47,7 @@ export class YXmlHook extends YMap {
* *
* @public * @public
*/ */
toDom (_document = document, hooks = {}, binding) { toDOM (_document = document, hooks = {}, binding) {
const hook = hooks[this.hookName] const hook = hooks[this.hookName]
let dom let dom
if (hook !== undefined) { if (hook !== undefined) {

View File

@@ -9,6 +9,10 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
* simple formatting information like bold and italic. * simple formatting information like bold and italic.
*/ */
export class YXmlText extends YText { export class YXmlText extends YText {
_copy () {
return new YXmlText()
}
/** /**
* Creates a Dom Element that mirrors this YXmlText. * Creates a Dom Element that mirrors this YXmlText.
* *
@@ -24,13 +28,55 @@ export class YXmlText extends YText {
* *
* @public * @public
*/ */
toDom (_document = document, hooks, binding) { toDOM (_document = document, hooks, binding) {
const dom = _document.createTextNode(this.toString()) const dom = _document.createTextNode(this.toString())
if (binding !== undefined) { if (binding !== undefined) {
binding._createAssociation(dom, this) binding._createAssociation(dom, this)
} }
return dom return dom
} }
toString () {
// @ts-ignore
return this.toDelta().map(delta => {
const nestedNodes = []
for (const nodeName in delta.attributes) {
const attrs = []
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
}).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* *

View File

@@ -3,18 +3,18 @@ import {
findIndexSS, findIndexSS,
createID, createID,
getState, getState,
AbstractItem, StructStore, Transaction, ID // eslint-disable-line splitItem,
iterateStructs,
Item, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as array from 'lib0/array.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
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'
/** export class DeleteItem {
* @private
*/
class DeleteItem {
/** /**
* @param {number} clock * @param {number} clock
* @param {number} len * @param {number} len
@@ -37,8 +37,6 @@ class DeleteItem {
* - This DeleteSet is send to other clients * - This DeleteSet is send to other clients
* - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore * - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore
* - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged. * - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged.
*
* @private
*/ */
export class DeleteSet { export class DeleteSet {
constructor () { constructor () {
@@ -50,6 +48,24 @@ export class DeleteSet {
} }
} }
/**
* Iterate over all structs that the DeleteSet gc's.
*
* @param {Transaction} transaction
* @param {DeleteSet} ds
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateDeletedStructs = (transaction, ds, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) {
const del = deletes[i]
iterateStructs(transaction, structs, del.clock, del.len, f)
}
})
/** /**
* @param {Array<DeleteItem>} dis * @param {Array<DeleteItem>} dis
* @param {number} clock * @param {number} clock
@@ -120,6 +136,32 @@ export const sortAndMergeDeleteSet = ds => {
}) })
} }
/**
* @param {Array<DeleteSet>} dss
* @return {DeleteSet} A fresh DeleteSet
*/
export const mergeDeleteSets = dss => {
const merged = new DeleteSet()
for (let dssI = 0; dssI < dss.length; dssI++) {
dss[dssI].clients.forEach((delsLeft, client) => {
if (!merged.clients.has(client)) {
// Write all missing keys from current ds and all following.
// If merged already contains `client` current ds has already been added.
/**
* @type {Array<DeleteItem>}
*/
const dels = delsLeft.slice()
for (let i = dssI + 1; i < dss.length; i++) {
array.appendTo(dels, dss[i].clients.get(client) || [])
}
merged.clients.set(client, dels)
}
})
}
sortAndMergeDeleteSet(merged)
return merged
}
/** /**
* @param {DeleteSet} ds * @param {DeleteSet} ds
* @param {ID} id * @param {ID} id
@@ -132,6 +174,8 @@ export const addToDeleteSet = (ds, id, length) => {
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length)) map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
} }
export const createDeleteSet = () => new DeleteSet()
/** /**
* @param {StructStore} ss * @param {StructStore} ss
* @return {DeleteSet} Merged and sorted DeleteSet * @return {DeleteSet} Merged and sorted DeleteSet
@@ -140,7 +184,7 @@ export const addToDeleteSet = (ds, id, length) => {
* @function * @function
*/ */
export const createDeleteSetFromStructStore = ss => { export const createDeleteSetFromStructStore = ss => {
const ds = new DeleteSet() const ds = createDeleteSet()
ss.clients.forEach((structs, client) => { ss.clients.forEach((structs, client) => {
/** /**
* @type {Array<DeleteItem>} * @type {Array<DeleteItem>}
@@ -187,6 +231,26 @@ export const writeDeleteSet = (encoder, ds) => {
}) })
} }
/**
* @param {decoding.Decoder} decoder
* @return {DeleteSet}
*
* @private
* @function
*/
export const readDeleteSet = decoder => {
const ds = new DeleteSet()
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
const client = decoding.readVarUint(decoder)
const numberOfDeletes = decoding.readVarUint(decoder)
for (let i = 0; i < numberOfDeletes; i++) {
addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder))
}
}
return ds
}
/** /**
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @param {Transaction} transaction * @param {Transaction} transaction
@@ -195,7 +259,7 @@ export const writeDeleteSet = (encoder, ds) => {
* @private * @private
* @function * @function
*/ */
export const readDeleteSet = (decoder, transaction, store) => { export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet() const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder) const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) { for (let i = 0; i < numClients; i++) {
@@ -213,13 +277,13 @@ export const readDeleteSet = (decoder, transaction, store) => {
let index = findIndexSS(structs, clock) let index = findIndexSS(structs, clock)
/** /**
* We can ignore the case of GC and Delete structs, because we are going to skip them * We can ignore the case of GC and Delete structs, because we are going to skip them
* @type {AbstractItem} * @type {Item}
*/ */
// @ts-ignore // @ts-ignore
let struct = structs[index] let struct = structs[index]
// split the first item if necessary // split the first item if necessary
if (!struct.deleted && struct.id.clock < clock) { if (!struct.deleted && struct.id.clock < clock) {
structs.splice(index + 1, 0, struct.splitAt(transaction, clock - struct.id.clock)) structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
index++ // increase we now want to use the next struct index++ // increase we now want to use the next struct
} }
while (index < structs.length) { while (index < structs.length) {
@@ -228,7 +292,7 @@ export const readDeleteSet = (decoder, transaction, store) => {
if (struct.id.clock < clock + len) { if (struct.id.clock < clock + len) {
if (!struct.deleted) { if (!struct.deleted) {
if (clock + len < struct.id.clock + struct.length) { if (clock + len < struct.id.clock + struct.length) {
structs.splice(index, 0, struct.splitAt(transaction, clock + len - struct.id.clock)) structs.splice(index, 0, splitItem(transaction, struct, clock + len - struct.id.clock))
} }
struct.delete(transaction) struct.delete(transaction)
} }
@@ -242,8 +306,9 @@ export const readDeleteSet = (decoder, transaction, store) => {
} }
} }
if (unappliedDS.clients.size > 0) { if (unappliedDS.clients.size > 0) {
// TODO: no need for encoding+decoding ds anymore
const unappliedDSEncoder = encoding.createEncoder() const unappliedDSEncoder = encoding.createEncoder()
writeDeleteSet(unappliedDSEncoder, unappliedDS) writeDeleteSet(unappliedDSEncoder, unappliedDS)
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toBuffer(unappliedDSEncoder))) store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))
} }
} }

View File

@@ -10,7 +10,7 @@ import {
YMap, YMap,
YXmlFragment, YXmlFragment,
transact, transact,
Transaction, YEvent // eslint-disable-line Item, Transaction, YEvent // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import { Observable } from 'lib0/observable.js' import { Observable } from 'lib0/observable.js'
@@ -21,13 +21,16 @@ import * as map from 'lib0/map.js'
* A Yjs instance handles the state of shared data. * A Yjs instance handles the state of shared data.
* @extends Observable<string> * @extends Observable<string>
*/ */
export class Y extends Observable { export class Doc extends Observable {
/** /**
* @param {Object|undefined} conf configuration * @param {Object} conf configuration
* @param {boolean} [conf.gc] Disable garbage collection (default: gc=true)
* @param {function(Item):boolean} [conf.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
*/ */
constructor (conf = {}) { constructor ({ gc = true, gcFilter = () => true } = {}) {
super() super()
// todo: change to clientId this.gc = gc
this.gcFilter = gcFilter
this.clientID = random.uint32() this.clientID = random.uint32()
/** /**
* @type {Map<string, AbstractType<YEvent>>} * @type {Map<string, AbstractType<YEvent>>}
@@ -39,7 +42,13 @@ export class Y extends Observable {
* @private * @private
*/ */
this._transaction = null this._transaction = null
/**
* @type {Array<Transaction>}
* @private
*/
this._transactionCleanups = []
} }
/** /**
* Changes that happen inside of a transaction are bundled. This means that * Changes that happen inside of a transaction are bundled. This means that
* the observer fires _after_ the transaction is finished and that all changes * the observer fires _after_ the transaction is finished and that all changes
@@ -47,12 +56,14 @@ export class Y extends Observable {
* other peers. * other peers.
* *
* @param {function(Transaction):void} f The function that should be executed as a transaction * @param {function(Transaction):void} f The function that should be executed as a transaction
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
* *
* @public * @public
*/ */
transact (f) { transact (f, origin = null) {
transact(this, f) transact(this, f, origin)
} }
/** /**
* Define a shared data type. * Define a shared data type.
* *
@@ -74,7 +85,7 @@ export class Y extends Observable {
* } * }
* *
* @param {string} name * @param {string} name
* @param {Function} TypeConstructor The constructor of the type definition * @param {Function} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ...
* @return {AbstractType<any>} The created type. Constructed with TypeConstructor * @return {AbstractType<any>} The created type. Constructed with TypeConstructor
* *
* @public * @public
@@ -89,9 +100,18 @@ export class Y extends Observable {
const Constr = type.constructor const Constr = type.constructor
if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) { if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) {
if (Constr === AbstractType) { if (Constr === AbstractType) {
const t = new Constr() // @ts-ignore
const t = new TypeConstructor()
t._map = type._map t._map = type._map
type._map.forEach(/** @param {Item?} n */ n => {
for (; n !== null; n = n.left) {
n.parent = t
}
})
t._start = type._start t._start = type._start
for (let n = t._start; n !== null; n = n.right) {
n.parent = t
}
t._length = type._length t._length = type._length
this.share.set(name, t) this.share.set(name, t)
t._integrate(this, null) t._integrate(this, null)
@@ -102,6 +122,7 @@ export class Y extends Observable {
} }
return type return type
} }
/** /**
* @template T * @template T
* @param {string} name * @param {string} name
@@ -113,6 +134,7 @@ export class Y extends Observable {
// @ts-ignore // @ts-ignore
return this.get(name, YArray) return this.get(name, YArray)
} }
/** /**
* @param {string} name * @param {string} name
* @return {YText} * @return {YText}
@@ -123,6 +145,7 @@ export class Y extends Observable {
// @ts-ignore // @ts-ignore
return this.get(name, YText) return this.get(name, YText)
} }
/** /**
* @param {string} name * @param {string} name
* @return {YMap<any>} * @return {YMap<any>}
@@ -133,6 +156,7 @@ export class Y extends Observable {
// @ts-ignore // @ts-ignore
return this.get(name, YMap) return this.get(name, YMap)
} }
/** /**
* @param {string} name * @param {string} name
* @return {YXmlFragment} * @return {YXmlFragment}
@@ -143,6 +167,7 @@ export class Y extends Observable {
// @ts-ignore // @ts-ignore
return this.get(name, YXmlFragment) return this.get(name, YXmlFragment)
} }
/** /**
* Emit `destroy` event and unregister all event handlers. * Emit `destroy` event and unregister all event handlers.
* *
@@ -152,6 +177,7 @@ export class Y extends Observable {
this.emit('destroyed', [true]) this.emit('destroyed', [true])
super.destroy() super.destroy()
} }
/** /**
* @param {string} eventName * @param {string} eventName
* @param {function} f * @param {function} f
@@ -159,6 +185,7 @@ export class Y extends Observable {
on (eventName, f) { on (eventName, f) {
super.on(eventName, f) super.on(eventName, f)
} }
/** /**
* @param {string} eventName * @param {string} eventName
* @param {function} f * @param {function} f

View File

@@ -1,5 +1,5 @@
import { AbstractType } from '../internals' // eslint-disable-line import { AbstractType } from '../internals.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
@@ -22,16 +22,6 @@ export class ID {
*/ */
this.clock = clock this.clock = clock
} }
/**
* @deprecated
* @todo remove and adapt relative position implementation
*/
toJSON () {
return {
client: this.client,
clock: this.clock
}
}
} }
/** /**
@@ -91,7 +81,7 @@ export const readID = decoder =>
*/ */
export const findRootTypeKey = type => { export const findRootTypeKey = type => {
// @ts-ignore _y must be defined, otherwise unexpected case // @ts-ignore _y must be defined, otherwise unexpected case
for (let [key, value] of type._y.share) { for (const [key, value] of type.doc.share) {
if (value === type) { if (value === type) {
return key return key
} }

View File

@@ -0,0 +1,142 @@
import {
YArray,
YMap,
readDeleteSet,
writeDeleteSet,
createDeleteSet,
ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
export class PermanentUserData {
/**
* @param {Doc} doc
* @param {YMap<any>} [storeType]
*/
constructor (doc, storeType = doc.getMap('users')) {
/**
* @type {Map<string,DeleteSet>}
*/
const dss = new Map()
this.yusers = storeType
this.doc = doc
/**
* Maps from clientid to userDescription
*
* @type {Map<number,string>}
*/
this.clients = new Map()
this.dss = dss
/**
* @param {YMap<any>} user
* @param {string} userDescription
*/
const initUser = (user, userDescription) => {
/**
* @type {YArray<Uint8Array>}
*/
const ds = user.get('ds')
const ids = user.get('ids')
const addClientId = /** @param {number} clientid */ clientid => this.clients.set(clientid, userDescription)
ds.observe(/** @param {YArrayEvent<any>} event */ event => {
event.changes.added.forEach(item => {
item.content.getContent().forEach(encodedDs => {
if (encodedDs instanceof Uint8Array) {
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(decoding.createDecoder(encodedDs))]))
}
})
})
})
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(decoding.createDecoder(encodedDs)))))
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
)
ids.forEach(addClientId)
}
// observe users
storeType.observe(event => {
event.keysChanged.forEach(userDescription =>
initUser(storeType.get(userDescription), userDescription)
)
})
// add intial data
storeType.forEach(initUser)
}
/**
* @param {Doc} doc
* @param {number} clientid
* @param {string} userDescription
* @param {Object} [conf]
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
*/
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
const users = this.yusers
let user = users.get(userDescription)
if (!user) {
user = new YMap()
user.set('ids', new YArray())
user.set('ds', new YArray())
users.set(userDescription, user)
}
user.get('ids').push([clientid])
users.observe(event => {
setTimeout(() => {
const userOverwrite = users.get(userDescription)
if (userOverwrite !== user) {
// user was overwritten, port all data over to the next user object
// @todo Experiment with Y.Sets here
user = userOverwrite
// @todo iterate over old type
this.clients.forEach((_userDescription, clientid) => {
if (userDescription === _userDescription) {
user.get('ids').push([clientid])
}
})
const encoder = encoding.createEncoder()
const ds = this.dss.get(userDescription)
if (ds) {
writeDeleteSet(encoder, ds)
user.get('ds').push([encoding.toUint8Array(encoder)])
}
}
}, 0)
})
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
setTimeout(() => {
const yds = user.get('ds')
const ds = transaction.deleteSet
if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) {
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, ds)
yds.push([encoding.toUint8Array(encoder)])
}
})
})
}
/**
* @param {number} clientid
* @return {any}
*/
getUserByClientId (clientid) {
return this.clients.get(clientid) || null
}
/**
* @param {ID} id
* @return {string | null}
*/
getUserByDeletedId (id) {
for (const [userDescription, ds] of this.dss) {
if (isDeleted(ds, id)) {
return userDescription
}
}
return null
}
}

View File

@@ -0,0 +1,272 @@
import {
createID,
writeID,
readID,
compareIDs,
getState,
findRootTypeKey,
Item,
ContentType,
followRedone,
ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* A relative position is based on the Yjs model and is not affected by document changes.
* E.g. If you place a relative position before a certain character, it will always point to this character.
* If you place a relative position at the end of a type, it will always point to the end of the type.
*
* A numeric position is often unsuited for user selections, because it does not change when content is inserted
* before or after.
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the relative position.
*
* One of the properties must be defined.
*
* @example
* // Current cursor position is at position 10
* const relativePosition = createRelativePositionFromIndex(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* const absolutePosition = createAbsolutePositionFromRelativePosition(y, relativePosition)
* absolutePosition.type === yText // => true
* console.log('cursor location is ' + absolutePosition.index) // => cursor location is 3
*
*/
export class RelativePosition {
/**
* @param {ID|null} type
* @param {string|null} tname
* @param {ID|null} item
*/
constructor (type, tname, item) {
/**
* @type {ID|null}
*/
this.type = type
/**
* @type {string|null}
*/
this.tname = tname
/**
* @type {ID | null}
*/
this.item = item
}
}
/**
* @param {any} json
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock))
export class AbsolutePosition {
/**
* @param {AbstractType<any>} type
* @param {number} index
*/
constructor (type, index) {
/**
* @type {AbstractType<any>}
*/
this.type = type
/**
* @type {number}
*/
this.index = index
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
*
* @function
*/
export const createAbsolutePosition = (type, index) => new AbsolutePosition(type, index)
/**
* @param {AbstractType<any>} type
* @param {ID|null} item
*
* @function
*/
export const createRelativePosition = (type, item) => {
let typeid = null
let tname = null
if (type._item === null) {
tname = findRootTypeKey(type)
} else {
typeid = type._item.id
}
return new RelativePosition(typeid, tname, item)
}
/**
* Create a relativePosition based on a absolute position.
*
* @param {AbstractType<any>} type The base type (e.g. YText or YArray).
* @param {number} index The absolute position.
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromTypeIndex = (type, index) => {
let t = type._start
while (t !== null) {
if (!t.deleted && t.countable) {
if (t.length > index) {
// case 1: found position somewhere in the linked list
return createRelativePosition(type, createID(t.id.client, t.id.clock + index))
}
index -= t.length
}
t = t.right
}
return createRelativePosition(type, null)
}
/**
* @param {encoding.Encoder} encoder
* @param {RelativePosition} rpos
*
* @function
*/
export const writeRelativePosition = (encoder, rpos) => {
const { type, tname, item } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
writeID(encoder, item)
} else if (tname !== null) {
// case 2: found position at the end of the list and type is stored in y.share
encoding.writeUint8(encoder, 1)
encoding.writeVarString(encoder, tname)
} else if (type !== null) {
// case 3: found position at the end of the list and type is attached to an item
encoding.writeUint8(encoder, 2)
writeID(encoder, type)
} else {
throw error.unexpectedCase()
}
return encoder
}
/**
* @param {RelativePosition} rpos
* @return {Uint8Array}
*/
export const encodeRelativePosition = rpos => {
const encoder = encoding.createEncoder()
writeRelativePosition(encoder, rpos)
return encoding.toUint8Array(encoder)
}
/**
* @param {decoding.Decoder} decoder
* @return {RelativePosition|null}
*
* @function
*/
export const readRelativePosition = decoder => {
let type = null
let tname = null
let itemID = null
switch (decoding.readVarUint(decoder)) {
case 0:
// case 1: found position somewhere in the linked list
itemID = readID(decoder)
break
case 1:
// case 2: found position at the end of the list and type is stored in y.share
tname = decoding.readVarString(decoder)
break
case 2: {
// case 3: found position at the end of the list and type is attached to an item
type = readID(decoder)
}
}
return new RelativePosition(type, tname, itemID)
}
/**
* @param {Uint8Array} uint8Array
* @return {RelativePosition|null}
*/
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
/**
* @param {RelativePosition} rpos
* @param {Doc} doc
* @return {AbsolutePosition|null}
*
* @function
*/
export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
const store = doc.store
const rightID = rpos.item
const typeID = rpos.type
const tname = rpos.tname
let type = null
let index = 0
if (rightID !== null) {
if (getState(store, rightID.client) <= rightID.clock) {
return null
}
const res = followRedone(store, rightID)
const right = res.item
if (!(right instanceof Item)) {
return null
}
type = right.parent
if (type._item === null || !type._item.deleted) {
index = right.deleted || !right.countable ? 0 : res.diff
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
index += n.length
}
n = n.left
}
}
} else {
if (tname !== null) {
type = doc.get(tname)
} else if (typeID !== null) {
if (getState(store, typeID.client) <= typeID.clock) {
// type does not exist yet
return null
}
const { item } = followRedone(store, typeID)
if (item instanceof Item && item.content instanceof ContentType) {
type = item.content.type
} else {
// struct is garbage collected
return null
}
} else {
throw error.unexpectedCase()
}
index = type._length
}
return createAbsolutePosition(type, index)
}
/**
* @param {RelativePosition|null} a
* @param {RelativePosition|null} b
*
* @function
*/
export const compareRelativePositions = (a, b) => a === b || (
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type)
)

View File

@@ -1,44 +1,140 @@
import { import {
DeleteSet,
isDeleted, isDeleted,
AbstractItem // eslint-disable-line createDeleteSetFromStructStore,
getStateVector,
getItemCleanStart,
createID,
iterateDeletedStructs,
writeDeleteSet,
writeStateVector,
readDeleteSet,
readStateVector,
createDeleteSet,
getState,
Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as map from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class Snapshot { export class Snapshot {
/** /**
* @param {DeleteSet} ds delete store * @param {DeleteSet} ds
* @param {Map<number,number>} sm state map * @param {Map<number,number>} sv state map
* @param {Map<number,string>} userMap
* @private
*/ */
constructor (ds, sm, userMap) { constructor (ds, sv) {
/** /**
* @type {DeleteSet} * @type {DeleteSet}
* @private * @private
*/ */
this.ds = new DeleteSet() this.ds = ds
/** /**
* State Map * State Map
* @type {Map<number,number>} * @type {Map<number,number>}
* @private * @private
*/ */
this.sm = sm this.sv = sv
/**
* @type {Map<number,string>}
* @private
*/
this.userMap = userMap
} }
} }
/** /**
* @param {AbstractItem} item * @param {Snapshot} snap1
* @param {Snapshot} snap2
* @return {boolean}
*/
export const equalSnapshots = (snap1, snap2) => {
const ds1 = snap1.ds.clients
const ds2 = snap2.ds.clients
const sv1 = snap1.sv
const sv2 = snap2.sv
if (sv1.size !== sv2.size || ds1.size !== ds2.size) {
return false
}
for (const [key, value] of sv1) {
if (sv2.get(key) !== value) {
return false
}
}
for (const [client, dsitems1] of ds1) {
const dsitems2 = ds2.get(client) || []
if (dsitems1.length !== dsitems2.length) {
return false
}
for (let i = 0; i < dsitems1.length; i++) {
const dsitem1 = dsitems1[i]
const dsitem2 = dsitems2[i]
if (dsitem1.clock !== dsitem2.clock || dsitem1.len !== dsitem2.len) {
return false
}
}
}
return true
}
/**
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => {
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, snapshot.ds)
writeStateVector(encoder, snapshot.sv)
return encoding.toUint8Array(encoder)
}
/**
* @param {Uint8Array} buf
* @return {Snapshot}
*/
export const decodeSnapshot = buf => {
const decoder = decoding.createDecoder(buf)
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
}
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm
* @return {Snapshot}
*/
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
export const emptySnapshot = createSnapshot(createDeleteSet(), new Map())
/**
* @param {Doc} doc
* @return {Snapshot}
*/
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
/**
* @param {Item} item
* @param {Snapshot|undefined} snapshot * @param {Snapshot|undefined} snapshot
* *
* @protected * @protected
* @function * @function
*/ */
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : ( export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
snapshot.sm.has(item.id.client) && (snapshot.sm.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id) snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
) )
/**
* @param {Transaction} transaction
* @param {Snapshot} snapshot
*/
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
const store = transaction.doc.store
// check if we already split for this snapshot
if (!meta.has(snapshot)) {
snapshot.sv.forEach((clock, client) => {
if (clock < getState(store, client)) {
getItemCleanStart(transaction, createID(client, clock))
}
})
iterateDeletedStructs(transaction, snapshot.ds, item => {})
meta.add(snapshot)
}
}

View File

@@ -1,18 +1,18 @@
import { import {
GC, GC,
Transaction, AbstractStructRef, ID, ItemType, AbstractItem, AbstractStruct // eslint-disable-line splitItem,
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'
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js'
export class StructStore { export class StructStore {
constructor () { constructor () {
/** /**
* @type {Map<number,Array<AbstractStruct>>} * @type {Map<number,Array<GC|Item>>}
* @private * @private
*/ */
this.clients = new Map() this.clients = new Map()
@@ -22,14 +22,14 @@ export class StructStore {
* We could shift the array of refs instead, but shift is incredible * We could shift the array of refs instead, but shift is incredible
* 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<AbstractStructRef>}>} * @type {Map<number,{i:number,refs:Array<GCRef|ItemRef>}>}
* @private * @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<AbstractStructRef>} * @type {Array<GCRef|ItemRef>}
* @private * @private
*/ */
this.pendingStack = [] this.pendingStack = []
@@ -51,7 +51,7 @@ export class StructStore {
* @public * @public
* @function * @function
*/ */
export const getStates = store => { export const getStateVector = store => {
const sm = new Map() const sm = new Map()
store.clients.forEach((structs, client) => { store.clients.forEach((structs, client) => {
const struct = structs[structs.length - 1] const struct = structs[structs.length - 1]
@@ -97,7 +97,7 @@ export const integretyCheck = store => {
/** /**
* @param {StructStore} store * @param {StructStore} store
* @param {AbstractStruct} struct * @param {GC|Item} struct
* *
* @private * @private
* @function * @function
@@ -151,14 +151,14 @@ export const findIndexSS = (structs, clock) => {
* *
* @param {StructStore} store * @param {StructStore} store
* @param {ID} id * @param {ID} id
* @return {AbstractStruct} * @return {GC|Item}
* *
* @private * @private
* @function * @function
*/ */
export const find = (store, id) => { export const find = (store, id) => {
/** /**
* @type {Array<AbstractStruct>} * @type {Array<GC|Item>}
*/ */
// @ts-ignore // @ts-ignore
const structs = store.clients.get(id.client) const structs = store.clients.get(id.client)
@@ -170,7 +170,7 @@ export const find = (store, id) => {
* *
* @param {StructStore} store * @param {StructStore} store
* @param {ID} id * @param {ID} id
* @return {AbstractItem} * @return {Item}
* *
* @private * @private
* @function * @function
@@ -179,45 +179,33 @@ export const find = (store, id) => {
export const getItem = (store, id) => find(store, id) export const getItem = (store, id) => find(store, id)
/** /**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise. * @param {Transaction} transaction
* * @param {Array<Item|GC>} structs
* @param {StructStore} store * @param {number} clock
* @param {ID} id
* @return {ItemType}
*
* @private
* @function
*/ */
// @ts-ignore export const findIndexCleanStart = (transaction, structs, clock) => {
export const getItemType = (store, id) => find(store, id) const index = findIndexSS(structs, clock)
const struct = structs[index]
if (struct.id.clock < clock && struct instanceof Item) {
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
return index + 1
}
return index
}
/** /**
* 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 {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store
* @param {ID} id * @param {ID} id
* @return {AbstractItem} * @return {Item}
* *
* @private * @private
* @function * @function
*/ */
export const getItemCleanStart = (transaction, store, id) => { export const getItemCleanStart = (transaction, id) => {
/** const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
* @type {Array<AbstractItem>} return structs[findIndexCleanStart(transaction, structs, id.clock)]
*/
// @ts-ignore
const structs = store.clients.get(id.client)
const index = findIndexSS(structs, id.clock)
/**
* @type {AbstractItem}
*/
let struct = structs[index]
if (struct.id.clock < id.clock && struct.constructor !== GC) {
struct = struct.splitAt(transaction, id.clock - struct.id.clock)
structs.splice(index + 1, 0, struct)
}
return struct
} }
/** /**
@@ -226,21 +214,21 @@ export const getItemCleanStart = (transaction, store, id) => {
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
* @param {ID} id * @param {ID} id
* @return {AbstractItem} * @return {Item}
* *
* @private * @private
* @function * @function
*/ */
export const getItemCleanEnd = (transaction, store, id) => { export const getItemCleanEnd = (transaction, store, id) => {
/** /**
* @type {Array<AbstractItem>} * @type {Array<Item>}
*/ */
// @ts-ignore // @ts-ignore
const structs = store.clients.get(id.client) const structs = store.clients.get(id.client)
const index = findIndexSS(structs, id.clock) const index = findIndexSS(structs, id.clock)
const struct = structs[index] const struct = structs[index]
if (id.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) { if (id.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) {
structs.splice(index + 1, 0, struct.splitAt(transaction, id.clock - struct.id.clock + 1)) structs.splice(index + 1, 0, splitItem(transaction, struct, id.clock - struct.id.clock + 1))
} }
return struct return struct
} }
@@ -248,56 +236,40 @@ export const getItemCleanEnd = (transaction, store, id) => {
/** /**
* Replace `item` with `newitem` in store * Replace `item` with `newitem` in store
* @param {StructStore} store * @param {StructStore} store
* @param {AbstractStruct} struct * @param {GC|Item} struct
* @param {AbstractStruct} newStruct * @param {GC|Item} newStruct
* *
* @private * @private
* @function * @function
*/ */
export const replaceStruct = (store, struct, newStruct) => { export const replaceStruct = (store, struct, newStruct) => {
/** const structs = /** @type {Array<GC|Item>} */ (store.clients.get(struct.id.client))
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = store.clients.get(struct.id.client)
structs[findIndexSS(structs, struct.id.clock)] = newStruct structs[findIndexSS(structs, struct.id.clock)] = newStruct
} }
/** /**
* Read StateMap from Decoder and return as Map * Iterate over a range of structs
* *
* @param {decoding.Decoder} decoder * @param {Transaction} transaction
* @return {Map<number,number>} * @param {Array<Item|GC>} structs
* @param {number} clockStart Inclusive start
* @param {number} len
* @param {function(GC|Item):void} f
* *
* @private
* @function * @function
*/ */
export const readStatesAsMap = decoder => { export const iterateStructs = (transaction, structs, clockStart, len, f) => {
const ss = new Map() if (len === 0) {
const ssLength = decoding.readVarUint(decoder) return
for (let i = 0; i < ssLength; i++) {
const client = decoding.readVarUint(decoder)
const clock = decoding.readVarUint(decoder)
ss.set(client, clock)
} }
return ss const clockEnd = clockStart + len
} let index = findIndexCleanStart(transaction, structs, clockStart)
let struct
/** do {
* Write StateMap to Encoder struct = structs[index++]
* if (clockEnd < struct.id.clock + struct.length) {
* @param {encoding.Encoder} encoder findIndexCleanStart(transaction, structs, clockEnd)
* @param {StructStore} store }
* f(struct)
* @private } while (index < structs.length && structs[index].id.clock < clockEnd)
* @function
*/
export const writeStates = (encoder, store) => {
encoding.writeVarUint(encoder, store.clients.size)
store.clients.forEach((structs, client) => {
const id = structs[structs.length - 1].id
encoding.writeVarUint(encoder, id.client)
encoding.writeVarUint(encoder, id.clock)
})
return encoder
} }

View File

@@ -6,17 +6,18 @@ import {
writeDeleteSet, writeDeleteSet,
DeleteSet, DeleteSet,
sortAndMergeDeleteSet, sortAndMergeDeleteSet,
getStates, getStateVector,
findIndexSS, findIndexSS,
callEventHandlerListeners, callEventHandlerListeners,
AbstractItem, Item,
ItemDeleted, StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
ID, AbstractType, AbstractStruct, YEvent, Y // 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 { callAll } from 'lib0/function.js'
/** /**
* A transaction is created for every change on the Yjs model. It is possible * A transaction is created for every change on the Yjs model. It is possible
@@ -44,14 +45,16 @@ import * as math from 'lib0/math.js'
*/ */
export class Transaction { export class Transaction {
/** /**
* @param {Y} y * @param {Doc} doc
* @param {any} origin
* @param {boolean} local
*/ */
constructor (y) { constructor (doc, origin, local) {
/** /**
* The Yjs instance. * The Yjs instance.
* @type {Y} * @type {Doc}
*/ */
this.y = y this.doc = doc
/** /**
* Describes the set of deleted items by ids * Describes the set of deleted items by ids
* @type {DeleteSet} * @type {DeleteSet}
@@ -61,7 +64,7 @@ export class Transaction {
* Holds the state before the transaction started. * Holds the state before the transaction started.
* @type {Map<Number,Number>} * @type {Map<Number,Number>}
*/ */
this.beforeState = getStates(y.store) this.beforeState = getStateVector(doc.store)
/** /**
* Holds the state after the transaction. * Holds the state after the transaction.
* @type {Map<Number,Number>} * @type {Map<Number,Number>}
@@ -70,7 +73,7 @@ export class Transaction {
/** /**
* All types that were directly modified (property added or child * All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set. * inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item._parentSub = null` for YArray) * Maps from type to parentSubs (`item.parentSub = null` for YArray)
* @type {Map<AbstractType<YEvent>,Set<String|null>>} * @type {Map<AbstractType<YEvent>,Set<String|null>>}
*/ */
this.changed = new Map() this.changed = new Map()
@@ -80,32 +83,40 @@ export class Transaction {
* @type {Map<AbstractType<YEvent>,Array<YEvent>>} * @type {Map<AbstractType<YEvent>,Array<YEvent>>}
*/ */
this.changedParentTypes = new Map() this.changedParentTypes = new Map()
/**
* @type {encoding.Encoder|null}
* @private
*/
this._updateMessage = null
/** /**
* @type {Set<ID>} * @type {Set<ID>}
* @private * @private
*/ */
this._mergeStructs = new Set() this._mergeStructs = new Set()
/**
* @type {any}
*/
this.origin = origin
/**
* Stores meta information on the transaction
* @type {Map<any,any>}
*/
this.meta = new Map()
/**
* Whether this change originates from this doc.
* @type {boolean}
*/
this.local = local
} }
/** }
* @type {encoding.Encoder|null}
* @public /**
*/ * @param {Transaction} transaction
get updateMessage () { */
// only create if content was added in transaction (state or ds changed) export const computeUpdateMessageFromTransaction = transaction => {
if (this._updateMessage === null && (this.deleteSet.clients.size > 0 || map.any(this.afterState, (clock, client) => this.beforeState.get(client) !== clock))) { if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
const encoder = encoding.createEncoder() return null
sortAndMergeDeleteSet(this.deleteSet)
writeStructsFromTransaction(encoder, this)
writeDeleteSet(encoder, this.deleteSet)
this._updateMessage = encoder
}
return this._updateMessage
} }
const encoder = encoding.createEncoder()
sortAndMergeDeleteSet(transaction.deleteSet)
writeStructsFromTransaction(encoder, transaction)
writeDeleteSet(encoder, transaction.deleteSet)
return encoder
} }
/** /**
@@ -115,105 +126,172 @@ export class Transaction {
* @function * @function
*/ */
export const nextID = transaction => { export const nextID = transaction => {
const y = transaction.y const y = transaction.doc
return createID(y.clientID, getState(y.store, y.clientID)) return createID(y.clientID, getState(y.store, y.clientID))
} }
/** /**
* Implements the functionality of `y.transact(()=>{..})` * If `type.parent` was added in current transaction, `type` technically
* did not change, it was just added and we should not fire events for `type`.
* *
* @param {Y} y * @param {Transaction} transaction
* @param {function(Transaction):void} f * @param {AbstractType<YEvent>} type
* * @param {string|null} parentSub
* @private
* @function
*/ */
export const transact = (y, f) => { export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
let initialCall = false const item = type._item
if (y._transaction === null) { if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) {
initialCall = true map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
y._transaction = new Transaction(y)
y.emit('beforeTransaction', [y._transaction, y])
} }
const transaction = y._transaction }
try {
f(transaction) /**
} finally { * @param {Array<AbstractStruct>} structs
if (initialCall) { * @param {number} pos
y._transaction = null */
y.emit('beforeObserverCalls', [transaction, y]) const tryToMergeWithLeft = (structs, pos) => {
// emit change events on changed types const left = structs[pos - 1]
transaction.changed.forEach((subs, itemtype) => { const right = structs[pos]
itemtype._callObserver(transaction, subs) if (left.deleted === right.deleted && left.constructor === right.constructor) {
}) if (left.mergeWith(right)) {
transaction.changedParentTypes.forEach((events, type) => { structs.splice(pos, 1)
events = events if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
.filter(event => right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
event.target._item === null || !event.target._item.deleted }
) }
events }
.forEach(event => { }
event.currentTarget = type
}) /**
// we don't need to check for events.length * @param {DeleteSet} ds
// because we know it has at least one element * @param {StructStore} store
callEventHandlerListeners(type._dEH, events, transaction) * @param {function(Item):boolean} gcFilter
}) */
// only call afterTransaction listeners if anything changed const tryGcDeleteSet = (ds, store, gcFilter) => {
transaction.afterState = getStates(transaction.y.store) for (const [client, deleteItems] of ds.clients) {
// when all changes & events are processed, emit afterTransaction event const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
// transaction cleanup for (let di = deleteItems.length - 1; di >= 0; di--) {
const store = transaction.y.store const deleteItem = deleteItems[di]
const ds = transaction.deleteSet const endDeleteItemClock = deleteItem.clock + deleteItem.len
// replace deleted items with ItemDeleted / GC 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<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)
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (ds, store, gcFilter) => {
tryGcDeleteSet(ds, store, gcFilter)
tryMergeDeleteSet(ds, store)
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
try {
sortAndMergeDeleteSet(ds) sortAndMergeDeleteSet(ds)
y.emit('afterTransaction', [transaction, y]) transaction.afterState = getStateVector(transaction.doc.store)
for (const [client, deleteItems] of ds.clients) { doc._transaction = null
/** doc.emit('beforeObserverCalls', [transaction, doc])
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = store.clients.get(client)
for (let di = 0; di < deleteItems.length; di++) {
const deleteItem = deleteItems[di]
for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct.deleted && struct instanceof AbstractItem && (struct.constructor !== ItemDeleted || (struct.parent._item !== null && struct.parent._item.deleted))) {
// check if we can GC
struct.gc(transaction, store)
}
}
}
}
/** /**
* @param {Array<AbstractStruct>} structs * An array of event callbacks.
* @param {number} pos *
* Each callback is called even if the other ones throw errors.
*
* @type {Array<function():void>}
*/ */
const tryToMergeWithLeft = (structs, pos) => { const fs = []
const left = structs[pos - 1] // observe events on changed types
const right = structs[pos] transaction.changed.forEach((subs, itemtype) =>
if (left.deleted === right.deleted && left.constructor === right.constructor) { fs.push(() => {
if (left.mergeWith(right)) { if (itemtype._item === null || !itemtype._item.deleted) {
structs.splice(pos, 1) itemtype._callObserver(transaction, subs)
if (right instanceof AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
// @ts-ignore we already did a constructor check above
right.parent._map.set(right.parentSub, left)
}
} }
} })
)
fs.push(() => {
// deep observe events
transaction.changedParentTypes.forEach((events, type) =>
fs.push(() => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
if (type._item === null || !type._item.deleted) {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
})
// We don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
}
})
)
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
})
callAll(fs, [])
} finally {
// Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc.
if (doc.gc) {
tryGcDeleteSet(ds, store, doc.gcFilter)
} }
tryMergeDeleteSet(ds, store)
// on all affected store.clients props, try to merge // 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))
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = 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--) {
@@ -222,14 +300,12 @@ export const transact = (y, f) => {
} }
} }
// 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
// but at the moment DS does not handle duplicates
for (const mid of transaction._mergeStructs) { for (const mid of transaction._mergeStructs) {
const client = mid.client const client = mid.client
const clock = mid.clock const clock = mid.clock
/** const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = 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)
@@ -238,7 +314,54 @@ export const transact = (y, f) => {
tryToMergeWithLeft(structs, replacedStructPos) tryToMergeWithLeft(structs, replacedStructPos)
} }
} }
y.emit('afterTransactionCleanup', [transaction, y]) // @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const updateMessage = computeUpdateMessageFromTransaction(transaction)
if (updateMessage !== null) {
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
}
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
} else {
cleanupTransactions(transactionCleanups, i + 1)
}
}
}
}
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @param {Doc} doc
* @param {function(Transaction):void} f
* @param {any} [origin=true]
*
* @function
*/
export const transact = (doc, f, origin = null, local = true) => {
const transactionCleanups = doc._transactionCleanups
let initialCall = false
if (doc._transaction === null) {
initialCall = true
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
doc.emit('beforeTransaction', [doc._transaction, doc])
}
try {
f(doc._transaction)
} finally {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
// Observer call may create new transactions for which we need to call the observers and do cleanup.
// We don't want to nest these calls, so we execute these calls one after
// another.
// Also we need to ensure that all cleanups are called, even if the
// observes throw errors.
// This file is full of hacky try {} finally {} blocks to ensure that an
// event can throw errors and also that the cleanup is called.
cleanupTransactions(transactionCleanups, 0)
} }
} }
} }

View File

@@ -1,202 +1,283 @@
// @ts-nocheck
import { import {
mergeDeleteSets,
iterateDeletedStructs,
keepItem,
transact,
redoItem,
iterateStructs,
isParentOf, isParentOf,
createID, createID,
transact followRedone,
getItemCleanStart,
getState,
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
/** import * as time from 'lib0/time.js'
* @private import { Observable } from 'lib0/observable.js'
*/
class ReverseOperation {
constructor (y, transaction, bindingInfos) {
this.created = new Date()
const beforeState = transaction.beforeState
if (beforeState.has(y.userID)) {
this.toState = createID(y.userID, y.ss.getState(y.userID) - 1)
this.fromState = createID(y.userID, beforeState.get(y.userID))
} else {
this.toState = null
this.fromState = null
}
this.deletedStructs = new Set()
transaction.deletedStructs.forEach(struct => {
this.deletedStructs.add({
from: struct._id,
len: struct._length
})
})
/**
* Maps from binding to binding information (e.g. cursor information)
*/
this.bindingInfos = bindingInfos
}
}
/** class StackItem {
* @private
* @function
*/
function applyReverseOperation (y, scope, reverseBuffer) {
let performedUndo = false
let undoOp = null
transact(y, () => {
while (!performedUndo && reverseBuffer.length > 0) {
undoOp = reverseBuffer.pop()
// make sure that it is possible to iterate {from}-{to}
if (undoOp.fromState !== null) {
y.os.getItemCleanStart(undoOp.fromState)
y.os.getItemCleanEnd(undoOp.toState)
y.os.iterate(undoOp.fromState, undoOp.toState, op => {
while (op._deleted && op._redone !== null) {
op = op._redone
}
if (op._deleted === false && isParentOf(scope, op)) {
performedUndo = true
op._delete(y)
}
})
}
const redoitems = new Set()
for (let del of undoOp.deletedStructs) {
const fromState = del.from
const toState = createID(fromState.user, fromState.clock + del.len - 1)
y.os.getItemCleanStart(fromState)
y.os.getItemCleanEnd(toState)
y.os.iterate(fromState, toState, op => {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
redoitems.add(op)
}
})
}
redoitems.forEach(op => {
const opUndone = op._redo(y, redoitems)
performedUndo = performedUndo || opUndone
})
}
})
if (performedUndo && undoOp !== null) {
// should be performed after the undo transaction
undoOp.bindingInfos.forEach((info, binding) => {
binding._restoreUndoStackInfo(info)
})
}
return performedUndo
}
/**
* Saves a history of locally applied operations. The UndoManager handles the
* undoing and redoing of locally created changes.
*
* @private
* @function
*/
export class UndoManager {
/** /**
* @param {YType} scope The scope on which to listen for changes. * @param {DeleteSet} ds
* @param {Object} options Optionally provided configuration. * @param {number} start clock start of the local client
* @param {number} len
*/ */
constructor (scope, options = {}) { constructor (ds, start, len) {
this.options = options this.ds = ds
this._bindings = new Set(options.bindings) this.start = start
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout this.len = len
this._undoBuffer = [] /**
this._redoBuffer = [] * Use this to save and restore metadata like selection range
this._scope = scope */
this._undoing = false this.meta = new Map()
this._redoing = false }
this._lastTransactionWasUndo = false }
const y = scope._y
this.y = y /**
let bindingInfos * @param {UndoManager} undoManager
y.on('beforeTransaction', (y, transaction, remote) => { * @param {Array<StackItem>} stack
if (!remote) { * @param {string} eventType
// Store binding information before transaction is executed * @return {StackItem?}
// By restoring the binding information, we can make sure that the state */
// before the transaction can be recovered const popStackItem = (undoManager, stack, eventType) => {
bindingInfos = new Map() /**
this._bindings.forEach(binding => { * Whether a change happened
bindingInfos.set(binding, binding._getUndoStackInfo()) * @type {StackItem?}
}) */
} let result = null
}) const doc = undoManager.doc
y.on('afterTransaction', (y, transaction, remote) => { const scope = undoManager.scope
if (!remote && transaction.changedParentTypes.has(scope)) { transact(doc, transaction => {
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos) while (stack.length > 0 && result === null) {
if (!this._undoing) { const store = doc.store
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null const clientID = doc.clientID
if ( const stackItem = /** @type {StackItem} */ (stack.pop())
this._redoing === false && const stackStartClock = stackItem.start
this._lastTransactionWasUndo === false && const stackEndClock = stackItem.start + stackItem.len
lastUndoOp !== null && const itemsToRedo = new Set()
((options.captureTimeout < 0) || (reverseOperation.created.getTime() - lastUndoOp.created.getTime()) <= options.captureTimeout) // @todo iterateStructs should not need the structs parameter
) { const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientID))
lastUndoOp.created = reverseOperation.created let performedChange = false
if (reverseOperation.toState !== null) { if (stackStartClock !== stackEndClock) {
lastUndoOp.toState = reverseOperation.toState // make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
if (lastUndoOp.fromState === null) { getItemCleanStart(transaction, createID(clientID, stackStartClock))
lastUndoOp.fromState = reverseOperation.fromState if (stackEndClock < getState(doc.store, clientID)) {
} getItemCleanStart(transaction, createID(clientID, stackEndClock))
}
reverseOperation.deletedStructs.forEach(lastUndoOp.deletedStructs.add, lastUndoOp.deletedStructs)
} else {
this._lastTransactionWasUndo = false
this._undoBuffer.push(reverseOperation)
}
if (!this._redoing) {
this._redoBuffer = []
}
} else {
this._lastTransactionWasUndo = true
this._redoBuffer.push(reverseOperation)
} }
} }
iterateDeletedStructs(transaction, stackItem.ds, struct => {
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(struct.id.client === clientID && struct.id.clock >= stackStartClock && struct.id.clock < stackEndClock)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
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
// parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
const item = itemsToDelete[i]
if (undoManager.deleteFilter(item)) {
item.delete(transaction)
performedChange = true
}
}
result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
}
}, undoManager)
return result
}
/**
* @typedef {Object} UndoManagerOptions
* @property {number} [UndoManagerOptions.captureTimeout=500]
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
* it is necessary to filter whan an Undo/Redo operation can delete. If this
* filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
*/
/**
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
* the redo-stack. You may store additional stack information via the
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
* Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
*
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
*/
export class UndoManager extends Observable {
/**
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {UndoManagerOptions} options
*/
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
this.deleteFilter = deleteFilter
trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins
/**
* @type {Array<StackItem>}
*/
this.undoStack = []
/**
* @type {Array<StackItem>}
*/
this.redoStack = []
/**
* Whether the client is currently undoing (calling UndoManager.undo)
*
* @type {boolean}
*/
this.undoing = false
this.redoing = false
this.doc = /** @type {Doc} */ (this.scope[0].doc)
this.lastChange = 0
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
// Only track certain transactions
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
return
}
const undoing = this.undoing
const redoing = this.redoing
const stack = undoing ? this.redoStack : this.undoStack
if (undoing) {
this.stopCapturing() // next undo should not be appended to last stack item
} else if (!redoing) {
// neither undoing nor redoing: delete redoStack
this.redoStack = []
}
const beforeState = transaction.beforeState.get(this.doc.clientID) || 0
const afterState = transaction.afterState.get(this.doc.clientID) || 0
const now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.len = afterState - lastOp.start
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState))
}
if (!undoing && !redoing) {
this.lastChange = now
}
// make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, true)
}
})
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
}) })
} }
/** clear () {
* Enforce that the next change is created as a separate item in the undo stack this.doc.transact(transaction => {
* /**
* @private * @param {StackItem} stackItem
* @function */
*/ const clearItem = stackItem => {
flushChanges () { iterateDeletedStructs(transaction, stackItem.ds, item => {
this._lastTransactionWasUndo = true 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 = []
} }
/** /**
* Undo the last locally created change. * UndoManager merges Undo-StackItem if they are created within time-gap
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
* StackItem won't be merged.
* *
* @private *
* @function * @example
* // without stopCapturing
* ytext.insert(0, 'a')
* ytext.insert(1, 'b')
* um.undo()
* ytext.toString() // => '' (note that 'ab' was removed)
* // with stopCapturing
* ytext.insert(0, 'a')
* um.stopCapturing()
* ytext.insert(0, 'b')
* um.undo()
* ytext.toString() // => 'a' (note that only 'b' was removed)
*
*/
stopCapturing () {
this.lastChange = 0
}
/**
* Undo last changes on type.
*
* @return {StackItem?} Returns StackItem if a change was applied
*/ */
undo () { undo () {
this._undoing = true this.undoing = true
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer) let res
this._undoing = false try {
return performedUndo res = popStackItem(this, this.undoStack, 'undo')
} finally {
this.undoing = false
}
return res
} }
/** /**
* Redo the last locally created change. * Redo last undo operation.
* *
* @private * @return {StackItem?} Returns StackItem if a change was applied
* @function
*/ */
redo () { redo () {
this._redoing = true this.redoing = true
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer) let res
this._redoing = false try {
return performedRedo res = popStackItem(this, this.redoStack, 'redo')
} finally {
this.redoing = false
}
return res
} }
} }

View File

@@ -1,9 +1,12 @@
import { import {
isDeleted, isDeleted,
AbstractType, Transaction, AbstractStruct // eslint-disable-line Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as set from 'lib0/set.js'
import * as array from 'lib0/array.js'
/** /**
* YEvent describes the changes on a YType. * YEvent describes the changes on a YType.
*/ */
@@ -28,6 +31,10 @@ export class YEvent {
* @type {Transaction} * @type {Transaction}
*/ */
this.transaction = transaction this.transaction = transaction
/**
* @type {Object|null}
*/
this._changes = null
} }
/** /**
@@ -49,6 +56,8 @@ export class YEvent {
/** /**
* Check if a struct is deleted by this event. * Check if a struct is deleted by this event.
* *
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct * @param {AbstractStruct} struct
* @return {boolean} * @return {boolean}
*/ */
@@ -59,12 +68,121 @@ export class YEvent {
/** /**
* Check if a struct is added by this event. * Check if a struct is added by this event.
* *
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct * @param {AbstractStruct} struct
* @return {boolean} * @return {boolean}
*/ */
adds (struct) { adds (struct) {
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0) return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
} }
/**
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
*/
get changes () {
let changes = this._changes
if (changes === null) {
const target = this.target
const added = set.create()
const deleted = set.create()
/**
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
/**
* @type {Map<string,{ action: 'add' | 'update' | 'delete', oldValue: any}>}
*/
const keys = new Map()
changes = {
added, deleted, delta, keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
/**
* @type {any}
*/
let lastOp = null
const packOp = () => {
if (lastOp) {
delta.push(lastOp)
}
}
for (let item = target._start; item !== null; item = item.right) {
if (item.deleted) {
if (this.deletes(item) && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
deleted.add(item)
} // else nop
} else {
if (this.adds(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
lastOp.insert = lastOp.insert.concat(item.content.getContent())
added.add(item)
} else {
if (lastOp === null || lastOp.retain === undefined) {
packOp()
lastOp = { retain: 0 }
}
lastOp.retain += item.length
}
}
}
if (lastOp !== null && lastOp.retain === undefined) {
packOp()
}
}
changed.forEach(key => {
if (key !== null) {
const item = /** @type {Item} */ (target._map.get(key))
/**
* @type {'delete' | 'add' | 'update'}
*/
let action
let oldValue
if (this.adds(item)) {
let prev = item.left
while (prev !== null && this.adds(prev)) {
prev = prev.left
}
if (this.deletes(item)) {
if (prev !== null && this.deletes(prev)) {
action = 'delete'
oldValue = array.last(prev.content.getContent())
} else {
return
}
} else {
if (prev !== null && this.deletes(prev)) {
action = 'update'
oldValue = array.last(prev.content.getContent())
} else {
action = 'add'
oldValue = undefined
}
}
} else {
if (this.deletes(item)) {
action = 'delete'
oldValue = array.last(/** @type {Item} */ item.content.getContent())
} else {
return // nop
}
}
keys.set(key, { action, oldValue })
}
})
this._changes = changes
}
return /** @type {any} */ (changes)
}
} }
/** /**

View File

@@ -1,266 +0,0 @@
/**
* @module Cursors
*/
import {
getItem,
getItemType,
createID,
writeID,
readID,
compareIDs,
getState,
findRootTypeKey,
AbstractItem,
ID, StructStore, Y, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as error from 'lib0/error.js'
/**
* A Cursor is a relative position that is based on the Yjs model. In contrast to an
* absolute position (position by index), the Cursor can be
* recomputed when remote changes are received. For example:
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
*
* A relative cursor position can be obtained with the function
*
* One of the properties must be defined.
*
* @example
* // Current cursor position is at position 10
* const relativePosition = createCursorFromOffset(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* const absolutePosition = toAbsolutePosition(y, relativePosition)
* absolutePosition.type === yText // => true
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
*
*/
export class Cursor {
/**
* @param {ID|null} type
* @param {string|null} tname
* @param {ID|null} item
*/
constructor (type, tname, item) {
/**
* @type {ID|null}
*/
this.type = type
/**
* @type {string|null}
*/
this.tname = tname
/**
* @type {ID | null}
*/
this.item = item
}
toJSON () {
const json = {}
if (this.type !== null) {
json.type = this.type.toJSON()
}
if (this.tname !== null) {
json.tname = this.tname
}
if (this.item !== null) {
json.item = this.item.toJSON()
}
return json
}
}
/**
* @param {Object} json
* @return {Cursor}
*
* @function
*/
export const createCursorFromJSON = json => new Cursor(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock))
export class AbsolutePosition {
/**
* @param {AbstractType<any>} type
* @param {number} offset
*/
constructor (type, offset) {
/**
* @type {AbstractType<any>}
*/
this.type = type
/**
* @type {number}
*/
this.offset = offset
}
}
/**
* @param {AbstractType<any>} type
* @param {number} offset
*
* @function
*/
export const createAbsolutePosition = (type, offset) => new AbsolutePosition(type, offset)
/**
* @param {AbstractType<any>} type
* @param {ID|null} item
*
* @function
*/
export const createCursor = (type, item) => {
let typeid = null
let tname = null
if (type._item === null) {
tname = findRootTypeKey(type)
} else {
typeid = type._item.id
}
return new Cursor(typeid, tname, item)
}
/**
* Create a relativePosition based on a absolute position.
*
* @param {AbstractType<any>} type The base type (e.g. YText or YArray).
* @param {number} offset The absolute position.
* @return {Cursor}
*
* @function
*/
export const createCursorFromTypeOffset = (type, offset) => {
let t = type._start
while (t !== null) {
if (!t.deleted && t.countable) {
if (t.length > offset) {
// case 1: found position somewhere in the linked list
return createCursor(type, createID(t.id.client, t.id.clock + offset))
}
offset -= t.length
}
t = t.right
}
return createCursor(type, null)
}
/**
* @param {encoding.Encoder} encoder
* @param {Cursor} rpos
*
* @function
*/
export const writeCursor = (encoder, rpos) => {
const { type, tname, item } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
writeID(encoder, item)
} else if (tname !== null) {
// case 2: found position at the end of the list and type is stored in y.share
encoding.writeUint8(encoder, 1)
encoding.writeVarString(encoder, tname)
} else if (type !== null) {
// case 3: found position at the end of the list and type is attached to an item
encoding.writeUint8(encoder, 2)
writeID(encoder, type)
} else {
throw error.unexpectedCase()
}
return encoder
}
/**
* @param {decoding.Decoder} decoder
* @param {Y} y
* @param {StructStore} store
* @return {Cursor|null}
*
* @function
*/
export const readCursor = (decoder, y, store) => {
let type = null
let tname = null
let itemID = null
switch (decoding.readVarUint(decoder)) {
case 0:
// case 1: found position somewhere in the linked list
itemID = readID(decoder)
break
case 1:
// case 2: found position at the end of the list and type is stored in y.share
tname = decoding.readVarString(decoder)
break
case 2: {
// case 3: found position at the end of the list and type is attached to an item
type = readID(decoder)
}
}
return new Cursor(type, tname, itemID)
}
/**
* @param {Cursor} cursor
* @param {Y} y
* @return {AbsolutePosition|null}
*
* @function
*/
export const createAbsolutePositionFromCursor = (cursor, y) => {
const store = y.store
const rightID = cursor.item
const typeID = cursor.type
const tname = cursor.tname
let type = null
let offset = 0
if (rightID !== null) {
if (getState(store, rightID.client) <= rightID.clock) {
return null
}
const right = getItem(store, rightID)
if (!(right instanceof AbstractItem)) {
return null
}
offset = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
offset += n.length
}
n = n.left
}
type = right.parent
} else {
if (tname !== null) {
type = y.get(tname)
} else if (typeID !== null) {
type = getItemType(store, typeID).type
} else {
throw error.unexpectedCase()
}
offset = type._length
}
if (type._item !== null && type._item.deleted) {
return null
}
return createAbsolutePosition(type, offset)
}
/**
* @param {Cursor|null} a
* @param {Cursor|null} b
*
* @function
*/
export const compareCursors = (a, b) => a === b || (
a !== null && b !== null && a.tname === b.tname && (
(a.item !== null && b.item !== null && compareIDs(a.item, b.item)) ||
(a.type !== null && b.type !== null && compareIDs(a.type, b.type))
)
)

View File

@@ -1,47 +1,39 @@
/** /**
* @module encoding * @module encoding
*
* We use the first five bits in the info flag for determining the type of the struct.
*
* 0: GC
* 1: Item with Deleted content
* 2: Item with JSON content
* 3: Item with Binary content
* 4: Item with String content
* 5: Item with Embed content (for richtext content)
* 6: Item with Format content (a formatting marker for richtext content)
* 7: Item with Type
*/ */
import { import {
findIndexSS, findIndexSS,
GCRef, GCRef,
ItemBinaryRef, ItemRef,
ItemDeletedRef,
ItemEmbedRef,
ItemFormatRef,
ItemJSONRef,
ItemStringRef,
ItemTypeRef,
writeID, writeID,
createID, createID,
readID, readID,
getState, getState,
getStates, getStateVector,
readDeleteSet, readAndApplyDeleteSet,
writeDeleteSet, writeDeleteSet,
createDeleteSetFromStructStore, createDeleteSetFromStructStore,
Transaction, AbstractStruct, AbstractStructRef, StructStore, ID // eslint-disable-line transact,
Doc, Transaction, AbstractStruct, 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'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
import * as binary from 'lib0/binary.js' import * as binary from 'lib0/binary.js'
/**
* @private
*/
export const structRefs = [
GCRef,
ItemBinaryRef,
ItemDeletedRef,
ItemEmbedRef,
ItemFormatRef,
ItemJSONRef,
ItemStringRef,
ItemTypeRef
]
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @param {Array<AbstractStruct>} structs All structs by `client` * @param {Array<AbstractStruct>} structs All structs by `client`
@@ -68,19 +60,19 @@ const writeStructs = (encoder, structs, client, clock) => {
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @param {number} numOfStructs * @param {number} numOfStructs
* @param {ID} nextID * @param {ID} nextID
* @return {Array<AbstractStructRef>} * @return {Array<GCRef|ItemRef>}
* *
* @private * @private
* @function * @function
*/ */
const readStructRefs = (decoder, numOfStructs, nextID) => { const readStructRefs = (decoder, numOfStructs, nextID) => {
/** /**
* @type {Array<AbstractStructRef>} * @type {Array<GCRef|ItemRef>}
*/ */
const refs = [] const refs = []
for (let i = 0; i < numOfStructs; i++) { for (let i = 0; i < numOfStructs; i++) {
const info = decoding.readUint8(decoder) const info = decoding.readUint8(decoder)
const ref = new structRefs[binary.BITS5 & info](decoder, nextID, info) const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, nextID, info) : new ItemRef(decoder, nextID, info)
nextID = createID(nextID.client, nextID.clock + ref.length) nextID = createID(nextID.client, nextID.clock + ref.length)
refs.push(ref) refs.push(ref)
} }
@@ -104,7 +96,7 @@ export const writeClientsStructs = (encoder, store, _sm) => {
sm.set(client, clock) sm.set(client, clock)
} }
}) })
getStates(store).forEach((clock, client) => { getStateVector(store).forEach((clock, client) => {
if (!_sm.has(client)) { if (!_sm.has(client)) {
sm.set(client, 0) sm.set(client, 0)
} }
@@ -119,14 +111,14 @@ 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.
* @return {Map<number,Array<AbstractStructRef>>} * @return {Map<number,Array<GCRef|ItemRef>>}
* *
* @private * @private
* @function * @function
*/ */
export const readClientsStructRefs = decoder => { export const readClientsStructRefs = decoder => {
/** /**
* @type {Map<number,Array<AbstractStructRef>>} * @type {Map<number,Array<GCRef|ItemRef>>}
*/ */
const clientRefs = new Map() const clientRefs = new Map()
const numOfStateUpdates = decoding.readVarUint(decoder) const numOfStateUpdates = decoding.readVarUint(decoder)
@@ -193,7 +185,7 @@ const resumeStructIntegration = (transaction, store) => {
structRefs.refs[structRefs.i] = ref structRefs.refs[structRefs.i] = ref
stack[stack.length - 1] = r stack[stack.length - 1] = r
// sort the set because this approach might bring the list out of order // sort the set because this approach might bring the list out of order
structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.client - r2.id.client) structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.clock - r2.id.clock)
structRefs.i = 0 structRefs.i = 0
continue continue
} }
@@ -239,7 +231,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
const pendingReaders = store.pendingDeleteReaders const pendingReaders = store.pendingDeleteReaders
store.pendingDeleteReaders = [] store.pendingDeleteReaders = []
for (let i = 0; i < pendingReaders.length; i++) { for (let i = 0; i < pendingReaders.length; i++) {
readDeleteSet(pendingReaders[i], transaction, store) readAndApplyDeleteSet(pendingReaders[i], transaction, store)
} }
} }
@@ -250,11 +242,11 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
* @private * @private
* @function * @function
*/ */
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.y.store, transaction.beforeState) export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
/** /**
* @param {StructStore} store * @param {StructStore} store
* @param {Map<number, Array<AbstractStructRef>>} clientsStructsRefs * @param {Map<number, Array<GCRef|ItemRef>>} clientsStructsRefs
* *
* @private * @private
* @function * @function
@@ -297,25 +289,135 @@ export const readStructs = (decoder, transaction, store) => {
} }
/** /**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts an decoder.
*
* @param {decoding.Decoder} decoder * @param {decoding.Decoder} decoder
* @param {Transaction} transaction * @param {Doc} ydoc
* @param {StructStore} store * @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* *
* @function * @function
*/ */
export const readModel = (decoder, transaction, store) => { export const readUpdate = (decoder, ydoc, transactionOrigin) =>
readStructs(decoder, transaction, store) transact(ydoc, transaction => {
readDeleteSet(decoder, transaction, store) readStructs(decoder, transaction, ydoc.store)
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin, false)
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
*
* @param {Doc} ydoc
* @param {Uint8Array} update
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const applyUpdate = (ydoc, update, transactionOrigin) =>
readUpdate(decoding.createDecoder(update), ydoc, transactionOrigin)
/**
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
* only write the operations that are missing.
*
* @param {encoding.Encoder} encoder
* @param {Doc} doc
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
*
* @function
*/
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
writeClientsStructs(encoder, doc.store, targetStateVector)
writeDeleteSet(encoder, createDeleteSetFromStructStore(doc.store))
} }
/** /**
* @param {encoding.Encoder} encoder * Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
* @param {StructStore} store * only write the operations that are missing.
* @param {Map<number,number>} [targetState] The state of the target that receives the update. Leave empty to write all known structs *
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @return {Uint8Array}
* *
* @function * @function
*/ */
export const writeModel = (encoder, store, targetState = new Map()) => { export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => {
writeClientsStructs(encoder, store, targetState) const encoder = encoding.createEncoder()
writeDeleteSet(encoder, createDeleteSetFromStructStore(store)) const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
writeStateAsUpdate(encoder, doc, targetStateVector)
return encoding.toUint8Array(encoder)
}
/**
* Read state vector from Decoder and return as Map
*
* @param {decoding.Decoder} decoder
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const readStateVector = decoder => {
const ss = new Map()
const ssLength = decoding.readVarUint(decoder)
for (let i = 0; i < ssLength; i++) {
const client = decoding.readVarUint(decoder)
const clock = decoding.readVarUint(decoder)
ss.set(client, clock)
}
return ss
}
/**
* Read decodedState and return State as Map.
*
* @param {Uint8Array} decodedState
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Map<number,number>} sv
* @function
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder, sv.size)
sv.forEach((clock, client) => {
encoding.writeVarUint(encoder, client)
encoding.writeVarUint(encoder, clock)
})
return encoder
}
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Doc} doc
*
* @function
*/
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
/**
* Encode State as Uint8Array.
*
* @param {Doc} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVector = doc => {
const encoder = encoding.createEncoder()
writeDocumentStateVector(encoder, doc)
return encoding.toUint8Array(encoder)
} }

View File

@@ -1,22 +1,22 @@
import { AbstractType } from '../internals.js' // eslint-disable-line import { AbstractType, Item } from '../internals.js' // eslint-disable-line
/** /**
* Check if `parent` is a parent of `child`. * Check if `parent` is a parent of `child`.
* *
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractType<any>} child * @param {Item|null} child
* @return {Boolean} Whether `parent` is a parent of `child`. * @return {Boolean} Whether `parent` is a parent of `child`.
* *
* @private * @private
* @function * @function
*/ */
export const isParentOf = (parent, child) => { export const isParentOf = (parent, child) => {
while (child._item !== null) { while (child !== null) {
if (child === parent) { if (child.parent === parent) {
return true return true
} }
child = child._item.parent child = child.parent._item
} }
return false return false
} }

View File

@@ -1,36 +1,28 @@
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import { import {
structRefs, contentRefs,
structGCRefNumber, readContentBinary,
structBinaryRefNumber, readContentDeleted,
structDeletedRefNumber, readContentString,
structEmbedRefNumber, readContentJSON,
structFormatRefNumber, readContentEmbed,
structJSONRefNumber, readContentType,
structStringRefNumber, readContentFormat,
structTypeRefNumber, readContentAny
GCRef,
ItemBinaryRef,
ItemDeletedRef,
ItemEmbedRef,
ItemFormatRef,
ItemJSONRef,
ItemStringRef,
ItemTypeRef
} from '../src/internals.js' } from '../src/internals.js'
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
export const testStructReferences = tc => { export const testStructReferences = tc => {
t.assert(structRefs.length === 8) t.assert(contentRefs.length === 9)
t.assert(structRefs[structGCRefNumber] === GCRef) t.assert(contentRefs[1] === readContentDeleted)
t.assert(structRefs[structBinaryRefNumber] === ItemBinaryRef) t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(structRefs[structDeletedRefNumber] === ItemDeletedRef) t.assert(contentRefs[3] === readContentBinary)
t.assert(structRefs[structEmbedRefNumber] === ItemEmbedRef) t.assert(contentRefs[4] === readContentString)
t.assert(structRefs[structFormatRefNumber] === ItemFormatRef) t.assert(contentRefs[5] === readContentEmbed)
t.assert(structRefs[structJSONRefNumber] === ItemJSONRef) t.assert(contentRefs[6] === readContentFormat)
t.assert(structRefs[structStringRefNumber] === ItemStringRef) t.assert(contentRefs[7] === readContentType)
t.assert(structRefs[structTypeRefNumber] === ItemTypeRef) t.assert(contentRefs[8] === readContentAny)
} }

View File

@@ -4,6 +4,7 @@ import * as map from './y-map.tests.js'
import * as text from './y-text.tests.js' 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 { 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'
@@ -13,7 +14,7 @@ if (isBrowser) {
log.createVConsole(document.body) log.createVConsole(document.body)
} }
runTests({ runTests({
map, array, text, xml, encoding map, array, text, xml, encoding, undoredo
}).then(success => { }).then(success => {
/* istanbul ignore next */ /* istanbul ignore next */
if (isNode) { if (isNode) {

View File

@@ -2,36 +2,22 @@ import * as Y from '../src/index.js'
import { import {
createDeleteSetFromStructStore, createDeleteSetFromStructStore,
getStates, getStateVector,
AbstractItem, Item,
DeleteSet, StructStore // eslint-disable-line DeleteItem, DeleteSet, StructStore, Doc // eslint-disable-line
} from '../src/internals.js' } from '../src/internals.js'
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js' import * as prng from 'lib0/prng.js'
import { createMutex } from 'lib0/mutex.js'
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'
import * as syncProtocol from 'y-protocols/sync.js' import * as syncProtocol from 'y-protocols/sync.js'
import * as object from 'lib0/object.js'
/** export * from '../src/internals.js'
* @param {TestYInstance} y
* @param {Y.Transaction} transaction
*/
const afterTransaction = (y, transaction) => {
y.mMux(() => {
const m = transaction.updateMessage
if (m !== null) {
const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, m)
broadcastMessage(y, encoding.toBuffer(encoder))
}
})
}
/** /**
* @param {TestYInstance} y // publish message created by `y` to all other online clients * @param {TestYInstance} y // publish message created by `y` to all other online clients
* @param {ArrayBuffer} m * @param {Uint8Array} m
*/ */
const broadcastMessage = (y, m) => { const broadcastMessage = (y, m) => {
if (y.tc.onlineConns.has(y)) { if (y.tc.onlineConns.has(y)) {
@@ -43,7 +29,7 @@ const broadcastMessage = (y, m) => {
} }
} }
export class TestYInstance extends Y.Y { export class TestYInstance extends Doc {
/** /**
* @param {TestConnector} testConnector * @param {TestConnector} testConnector
* @param {number} clientID * @param {number} clientID
@@ -56,19 +42,21 @@ export class TestYInstance extends Y.Y {
*/ */
this.tc = testConnector this.tc = testConnector
/** /**
* @type {Map<TestYInstance, Array<ArrayBuffer>>} * @type {Map<TestYInstance, Array<Uint8Array>>}
*/ */
this.receiving = new Map() this.receiving = new Map()
/**
* Message mutex
* @type {Function}
*/
this.mMux = createMutex()
testConnector.allConns.add(this) testConnector.allConns.add(this)
// set up observe on local model // set up observe on local model
this.on('afterTransactionCleanup', afterTransaction) this.on('update', /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
if (origin !== testConnector) {
const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, update)
broadcastMessage(this, encoding.toUint8Array(encoder))
}
})
this.connect() this.connect()
} }
/** /**
* Disconnect from TestConnector. * Disconnect from TestConnector.
*/ */
@@ -76,6 +64,7 @@ export class TestYInstance extends Y.Y {
this.receiving = new Map() this.receiving = new Map()
this.tc.onlineConns.delete(this) this.tc.onlineConns.delete(this)
} }
/** /**
* Append yourself to the list of known Y instances in testconnector. * Append yourself to the list of known Y instances in testconnector.
* Also initiate sync with all clients. * Also initiate sync with all clients.
@@ -84,24 +73,25 @@ export class TestYInstance extends Y.Y {
if (!this.tc.onlineConns.has(this)) { if (!this.tc.onlineConns.has(this)) {
this.tc.onlineConns.add(this) this.tc.onlineConns.add(this)
const encoder = encoding.createEncoder() const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, this.store) syncProtocol.writeSyncStep1(encoder, this)
// publish SyncStep1 // publish SyncStep1
broadcastMessage(this, encoding.toBuffer(encoder)) broadcastMessage(this, encoding.toUint8Array(encoder))
this.tc.onlineConns.forEach(remoteYInstance => { this.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== this) { if (remoteYInstance !== this) {
// remote instance sends instance to this instance // remote instance sends instance to this instance
const encoder = encoding.createEncoder() const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, remoteYInstance.store) syncProtocol.writeSyncStep1(encoder, remoteYInstance)
this._receive(encoding.toBuffer(encoder), remoteYInstance) this._receive(encoding.toUint8Array(encoder), remoteYInstance)
} }
}) })
} }
} }
/** /**
* Receive a message from another client. This message is only appended to the list of receiving messages. * Receive a message from another client. This message is only appended to the list of receiving messages.
* TestConnector decides when this client actually reads this message. * TestConnector decides when this client actually reads this message.
* *
* @param {ArrayBuffer} message * @param {Uint8Array} message
* @param {TestYInstance} remoteClient * @param {TestYInstance} remoteClient
*/ */
_receive (message, remoteClient) { _receive (message, remoteClient) {
@@ -138,6 +128,7 @@ export class TestConnector {
*/ */
this.prng = gen this.prng = gen
} }
/** /**
* Create a new Y instance and add it to the list of connections * Create a new Y instance and add it to the list of connections
* @param {number} clientID * @param {number} clientID
@@ -145,6 +136,7 @@ export class TestConnector {
createY (clientID) { createY (clientID) {
return new TestYInstance(this, clientID) return new TestYInstance(this, clientID)
} }
/** /**
* Choose random connection and flush a random message from a random sender. * Choose random connection and flush a random message from a random sender.
* *
@@ -165,19 +157,18 @@ export class TestConnector {
return this.flushRandomMessage() return this.flushRandomMessage()
} }
const encoder = encoding.createEncoder() const encoder = encoding.createEncoder()
receiver.mMux(() => { // console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver)) // do not publish data created when this function is executed (could be ss2 or update message)
// do not publish data created when this function is executed (could be ss2 or update message) syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver, receiver.tc)
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver)
})
if (encoding.length(encoder) > 0) { if (encoding.length(encoder) > 0) {
// send reply message // send reply message
sender._receive(encoding.toBuffer(encoder), receiver) sender._receive(encoding.toUint8Array(encoder), receiver)
} }
return true return true
} }
return false return false
} }
/** /**
* @return {boolean} True iff this function actually flushed something * @return {boolean} True iff this function actually flushed something
*/ */
@@ -188,16 +179,20 @@ export class TestConnector {
} }
return didSomething return didSomething
} }
reconnectAll () { reconnectAll () {
this.allConns.forEach(conn => conn.connect()) this.allConns.forEach(conn => conn.connect())
} }
disconnectAll () { disconnectAll () {
this.allConns.forEach(conn => conn.disconnect()) this.allConns.forEach(conn => conn.disconnect())
} }
syncAll () { syncAll () {
this.reconnectAll() this.reconnectAll()
this.flushAllMessages() this.flushAllMessages()
} }
/** /**
* @return {boolean} Whether it was possible to disconnect a randon connection. * @return {boolean} Whether it was possible to disconnect a randon connection.
*/ */
@@ -208,6 +203,7 @@ export class TestConnector {
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect() prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
return true return true
} }
/** /**
* @return {boolean} Whether it was possible to reconnect a random connection. * @return {boolean} Whether it was possible to reconnect a random connection.
*/ */
@@ -230,11 +226,13 @@ export class TestConnector {
} }
/** /**
* @template T
* @param {t.TestCase} tc * @param {t.TestCase} tc
* @param {{users?:number}} conf * @param {{users?:number}} conf
* @return {{testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}} * @param {InitTestObjectCallback<T>} [initTestObject]
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
*/ */
export const init = (tc, { users = 5 } = {}) => { export const init = (tc, { users = 5 } = {}, initTestObject) => {
/** /**
* @type {Object<string,any>} * @type {Object<string,any>}
*/ */
@@ -254,8 +252,8 @@ export const init = (tc, { users = 5 } = {}) => {
result['text' + i] = y.get('text', Y.Text) result['text' + i] = y.get('text', Y.Text)
} }
testConnector.syncAll() testConnector.syncAll()
// @ts-ignore result.testObjects = result.users.map(initTestObject || (() => null))
return result return /** @type {any} */ (result)
} }
/** /**
@@ -270,7 +268,7 @@ export const init = (tc, { users = 5 } = {}) => {
export const compare = users => { export const compare = users => {
users.forEach(u => u.connect()) users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {} while (users[0].tc.flushAllMessages()) {}
const userArrayValues = users.map(u => u.getArray('array').toJSON().map(val => JSON.stringify(val))) const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON()) const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString()) const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta()) const userTextValues = users.map(u => u.getText('text').toDelta())
@@ -279,15 +277,29 @@ export const compare = users => {
t.assert(u.store.pendingStack.length === 0) t.assert(u.store.pendingStack.length === 0)
t.assert(u.store.pendingClientsStructRefs.size === 0) t.assert(u.store.pendingClientsStructRefs.size === 0)
} }
// Test Array iterator
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))
// Test Map iterator
const ymapkeys = Array.from(users[0].getMap('map').keys())
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
/**
* @type {Object<string,any>}
*/
const mapRes = {}
for (const [k, v] of users[0].getMap('map')) {
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
}
t.compare(userMapValues[0], mapRes)
// Compare all users
for (let i = 0; i < users.length - 1; i++) { for (let i = 0; i < users.length - 1; i++) {
t.compare(userArrayValues[i].length, users[i].getArray('array').length) t.compare(userArrayValues[i].length, users[i].getArray('array').length)
t.compare(userArrayValues[i], userArrayValues[i + 1]) t.compare(userArrayValues[i], userArrayValues[i + 1])
t.compare(userMapValues[i], userMapValues[i + 1]) t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1]) t.compare(userXmlValues[i], userXmlValues[i + 1])
// @ts-ignore t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i].map(a => a.insert).join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1]) t.compare(userTextValues[i], userTextValues[i + 1])
t.compare(getStates(users[i].store), getStates(users[i + 1].store)) t.compare(getStateVector(users[i].store), getStateVector(users[i + 1].store))
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store)) compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
compareStructStores(users[i].store, users[i + 1].store) compareStructStores(users[i].store, users[i + 1].store)
} }
@@ -295,8 +307,8 @@ export const compare = users => {
} }
/** /**
* @param {AbstractItem?} a * @param {Item?} a
* @param {AbstractItem?} b * @param {Item?} b
* @return {boolean} * @return {boolean}
*/ */
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id)) export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
@@ -308,11 +320,10 @@ export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y
export const compareStructStores = (ss1, ss2) => { export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size) t.assert(ss1.clients.size === ss2.clients.size)
for (const [client, structs1] of ss1.clients) { for (const [client, structs1] of ss1.clients) {
const structs2 = ss2.clients.get(client) const structs2 = /** @type {Array<Y.AbstractStruct>} */ (ss2.clients.get(client))
t.assert(structs2 !== undefined && structs1.length === structs2.length) t.assert(structs2 !== undefined && structs1.length === structs2.length)
for (let i = 0; i < structs1.length; i++) { for (let i = 0; i < structs1.length; i++) {
const s1 = structs1[i] const s1 = structs1[i]
// @ts-ignore
const s2 = structs2[i] const s2 = structs2[i]
// checks for abstract struct // checks for abstract struct
if ( if (
@@ -323,9 +334,9 @@ export const compareStructStores = (ss1, ss2) => {
) { ) {
t.fail('Structs dont match') t.fail('Structs dont match')
} }
if (s1 instanceof AbstractItem) { if (s1 instanceof Item) {
if ( if (
!(s2 instanceof AbstractItem) || !(s2 instanceof Item) ||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) || !((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
!compareItemIDs(s1.right, s2.right) || !compareItemIDs(s1.right, s2.right) ||
!Y.compareIDs(s1.origin, s2.origin) || !Y.compareIDs(s1.origin, s2.origin) ||
@@ -351,11 +362,10 @@ export const compareStructStores = (ss1, ss2) => {
export const compareDS = (ds1, ds2) => { export const compareDS = (ds1, ds2) => {
t.assert(ds1.clients.size === ds2.clients.size) t.assert(ds1.clients.size === ds2.clients.size)
for (const [client, deleteItems1] of ds1.clients) { for (const [client, deleteItems1] of ds1.clients) {
const deleteItems2 = ds2.clients.get(client) const deleteItems2 = /** @type {Array<DeleteItem>} */ (ds2.clients.get(client))
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length) t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
for (let i = 0; i < deleteItems1.length; i++) { for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i] const di1 = deleteItems1[i]
// @ts-ignore
const di2 = deleteItems2[i] const di2 = deleteItems2[i]
if (di1.clock !== di2.clock || di1.len !== di2.len) { if (di1.clock !== di2.clock || di1.len !== di2.len) {
t.fail('DeleteSets dont match') t.fail('DeleteSets dont match')
@@ -365,15 +375,24 @@ export const compareDS = (ds1, ds2) => {
} }
/** /**
* @param {t.TestCase} tc * @template T
* @param {Array<function(TestYInstance,prng.PRNG):void>} mods * @callback InitTestObjectCallback
* @param {number} iterations * @param {TestYInstance} y
* @return {T}
*/ */
export const applyRandomTests = (tc, mods, iterations) => {
/**
* @template T
* @param {t.TestCase} tc
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
* @param {number} iterations
* @param {InitTestObjectCallback<T>} [initTestObject]
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng const gen = tc.prng
const result = init(tc, { users: 5 }) const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result const { testConnector, users } = result
for (var i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
if (prng.int31(gen, 0, 100) <= 2) { if (prng.int31(gen, 0, 100) <= 2) {
// 2% chance to disconnect/reconnect a random user // 2% chance to disconnect/reconnect a random user
if (prng.bool(gen)) { if (prng.bool(gen)) {
@@ -388,9 +407,9 @@ export const applyRandomTests = (tc, mods, iterations) => {
// 50% chance to flush a random message // 50% chance to flush a random message
testConnector.flushRandomMessage() testConnector.flushRandomMessage()
} }
let user = prng.oneOf(gen, users) const user = prng.int31(gen, 0, users.length - 1)
var test = prng.oneOf(gen, mods) const test = prng.oneOf(gen, mods)
test(user, gen) test(users[user], gen, result.testObjects[user])
} }
compare(users) compare(users)
return result return result

248
tests/undo-redo.tests.js Normal file
View File

@@ -0,0 +1,248 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import {
UndoManager
} from '../src/internals.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
/**
* @param {t.TestCase} tc
*/
export const testUndoText = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 3 })
const undoManager = new UndoManager(text0)
// items that are added & deleted in the same transaction won't be undo
text0.insert(0, 'test')
text0.delete(0, 4)
undoManager.undo()
t.assert(text0.toString() === '')
// follow redone items
text0.insert(0, 'a')
undoManager.stopCapturing()
text0.delete(0, 1)
undoManager.stopCapturing()
undoManager.undo()
t.assert(text0.toString() === 'a')
undoManager.undo()
t.assert(text0.toString() === '')
text0.insert(0, 'abc')
text1.insert(0, 'xyz')
testConnector.syncAll()
undoManager.undo()
t.assert(text0.toString() === 'xyz')
undoManager.redo()
t.assert(text0.toString() === 'abcxyz')
testConnector.syncAll()
text1.delete(0, 1)
testConnector.syncAll()
undoManager.undo()
t.assert(text0.toString() === 'xyz')
undoManager.redo()
t.assert(text0.toString() === 'bcxyz')
// test marks
text0.format(1, 3, { bold: true })
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
undoManager.undo()
t.compare(text0.toDelta(), [{ insert: 'bcxyz' }])
undoManager.redo()
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
}
/**
* @param {t.TestCase} tc
*/
export const testUndoMap = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 0)
const undoManager = new UndoManager(map0)
map0.set('a', 1)
undoManager.undo()
t.assert(map0.get('a') === 0)
undoManager.redo()
t.assert(map0.get('a') === 1)
// testing sub-types and if it can restore a whole type
const subType = new Y.Map()
map0.set('a', subType)
subType.set('x', 42)
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
undoManager.undo()
t.assert(map0.get('a') === 1)
undoManager.redo()
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
testConnector.syncAll()
// if content is overwritten by another user, undo operations should be skipped
map1.set('a', 44)
testConnector.syncAll()
undoManager.undo()
t.assert(map0.get('a') === 44)
undoManager.redo()
t.assert(map0.get('a') === 44)
// test setting value multiple times
map0.set('b', 'initial')
undoManager.stopCapturing()
map0.set('b', 'val1')
map0.set('b', 'val2')
undoManager.stopCapturing()
undoManager.undo()
t.assert(map0.get('b') === 'initial')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 3 })
const undoManager = new UndoManager(array0)
array0.insert(0, [1, 2, 3])
array1.insert(0, [4, 5, 6])
testConnector.syncAll()
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
undoManager.undo()
t.compare(array0.toArray(), [4, 5, 6])
undoManager.redo()
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
testConnector.syncAll()
array1.delete(0, 1) // user1 deletes [1]
testConnector.syncAll()
undoManager.undo()
t.compare(array0.toArray(), [4, 5, 6])
undoManager.redo()
t.compare(array0.toArray(), [2, 3, 4, 5, 6])
array0.delete(0, 5)
// test nested structure
const ymap = new Y.Map()
array0.insert(0, [ymap])
t.compare(array0.toJSON(), [{}])
undoManager.stopCapturing()
ymap.set('a', 1)
t.compare(array0.toJSON(), [{ a: 1 }])
undoManager.undo()
t.compare(array0.toJSON(), [{}])
undoManager.undo()
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON(), [{}])
undoManager.redo()
t.compare(array0.toJSON(), [{ a: 1 }])
testConnector.syncAll()
array1.get(0).set('b', 2)
testConnector.syncAll()
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
undoManager.undo()
t.compare(array0.toJSON(), [{ b: 2 }])
undoManager.undo()
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON(), [{ b: 2 }])
undoManager.redo()
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
}
/**
* @param {t.TestCase} tc
*/
export const testUndoXml = tc => {
const { xml0 } = init(tc, { users: 3 })
const undoManager = new UndoManager(xml0)
const child = new Y.XmlElement('p')
xml0.insert(0, [child])
const textchild = new Y.XmlText('content')
child.insert(0, [textchild])
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
// format textchild and revert that change
undoManager.stopCapturing()
textchild.format(3, 4, { bold: {} })
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
undoManager.redo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
xml0.delete(0, 1)
t.assert(xml0.toString() === '<undefined></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoEvents = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new UndoManager(text0)
let counter = 0
let receivedMetadata = -1
undoManager.on('stack-item-added', /** @param {any} event */ event => {
t.assert(event.type != null)
event.stackItem.meta.set('test', counter++)
})
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
t.assert(event.type != null)
receivedMetadata = event.stackItem.meta.get('test')
})
text0.insert(0, 'abc')
undoManager.undo()
t.assert(receivedMetadata === 0)
undoManager.redo()
t.assert(receivedMetadata === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testTrackClass = tc => {
const { users, text0 } = init(tc, { users: 3 })
// only track origins that are numbers
const undoManager = new UndoManager(text0, { trackedOrigins: new Set([Number]) })
users[0].transact(() => {
text0.insert(0, 'abc')
}, 42)
t.assert(text0.toString() === 'abc')
undoManager.undo()
t.assert(text0.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testTypeScope = tc => {
const { array0 } = init(tc, { users: 3 })
// only track origins that are numbers
const text0 = new Y.Text()
const text1 = new Y.Text()
array0.insert(0, [text0, text1])
const undoManager = new UndoManager(text0)
const undoManagerBoth = new UndoManager([text0, text1])
text1.insert(0, 'abc')
t.assert(undoManager.undoStack.length === 0)
t.assert(undoManagerBoth.undoStack.length === 1)
t.assert(text1.toString() === 'abc')
undoManager.undo()
t.assert(text1.toString() === 'abc')
undoManagerBoth.undo()
t.assert(text1.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoDeleteFilter = tc => {
/**
* @type {Array<Y.Map<any>>}
*/
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
const undoManager = new UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const map0 = new Y.Map()
map0.set('hi', 1)
const map1 = new Y.Map()
array0.insert(0, [map0, map1])
undoManager.undo()
t.assert(array0.length === 1)
array0.get(0)
t.assert(Array.from(array0.get(0).keys()).length === 1)
}

View File

@@ -1,8 +1,9 @@
import { init, compare, applyRandomTests, TestYInstance } from './testHelper.js' // eslint-disable-line import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js' import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js' import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
@@ -25,10 +26,10 @@ export const testDeleteInsert = tc => {
*/ */
export const testInsertThreeElementsTryRegetProperty = tc => { export const testInsertThreeElementsTryRegetProperty = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 }) const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, 2, 3]) array0.insert(0, [1, true, false])
t.compare(array0.toJSON(), [1, 2, 3], '.toJSON() works') t.compare(array0.toJSON(), [1, true, false], '.toJSON() works')
testConnector.flushAllMessages() testConnector.flushAllMessages()
t.compare(array1.toJSON(), [1, 2, 3], '.toJSON() works after sync') t.compare(array1.toJSON(), [1, true, false], '.toJSON() works after sync')
compare(users) compare(users)
} }
@@ -144,6 +145,32 @@ export const testInsertAndDeleteEvents = tc => {
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testNestedObserverEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<number>}
*/
const vals = []
array0.observe(e => {
if (array0.length === 1) {
// inserting, will call this observer again
// we expect that this observer is called after this event handler finishedn
array0.insert(1, [1])
vals.push(0)
} else {
// this should be called the second time an element is inserted (above case)
vals.push(1)
}
})
array0.insert(0, [0])
t.compareArrays(vals, [0, 1])
t.compareArrays(array0.toArray(), [0, 1])
compare(users)
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -165,6 +192,33 @@ export const testInsertAndDeleteEventsForTypes = tc => {
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testChangeEvent = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {any}
*/
let changes = null
array0.observe(e => {
changes = e.changes
})
const newArr = new Y.Array()
array0.insert(0, [newArr, 4, 'dtrn'])
t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0)
t.compare(changes.delta, [{ insert: [newArr, 4, 'dtrn'] }])
changes = null
array0.delete(0, 2)
t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2)
t.compare(changes.delta, [{ delete: 2 }])
changes = null
array0.insert(1, [0.1])
t.assert(changes !== null && changes.added.size === 1 && changes.deleted.size === 0)
t.compare(changes.delta, [{ retain: 1 }, { insert: [0.1] }])
compare(users)
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -173,7 +227,7 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
/** /**
* @type {Array<Object<string,any>>} * @type {Array<Object<string,any>>}
*/ */
let events = [] const events = []
array0.observe(e => { array0.observe(e => {
events.push(e) events.push(e)
}) })
@@ -184,6 +238,24 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
compare(users) compare(users)
} }
/**
* This issue has been reported here https://github.com/yjs/yjs/issues/155
* @param {t.TestCase} tc
*/
export const testNewChildDoesNotEmitEventInTransaction = tc => {
const { array0, users } = init(tc, { users: 2 })
let fired = false
users[0].transact(() => {
const newMap = new Y.Map()
newMap.observe(() => {
fired = true
})
array0.insert(0, [newMap])
newMap.set('tst', 42)
})
t.assert(!fired, 'Event does not trigger')
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -237,7 +309,7 @@ export const testEventTargetIsSetCorrectlyOnRemote = tc => {
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
export const testIteratingArrayContainingTypes = tc => { export const testIteratingArrayContainingTypes = tc => {
const y = new Y.Y() const y = new Y.Doc()
const arr = y.getArray('arr') const arr = y.getArray('arr')
const numItems = 10 const numItems = 10
for (let i = 0; i < numItems; i++) { for (let i = 0; i < numItems; i++) {
@@ -246,7 +318,7 @@ export const testIteratingArrayContainingTypes = tc => {
arr.push([map]) arr.push([map])
} }
let cnt = 0 let cnt = 0
for (let item of arr) { for (const item of arr) {
t.assert(item.get('value') === cnt++, 'value is correct') t.assert(item.get('value') === cnt++, 'value is correct')
} }
y.destroy() y.destroy()
@@ -256,7 +328,7 @@ let _uniqueNumber = 0
const getUniqueNumber = () => _uniqueNumber++ const getUniqueNumber = () => _uniqueNumber++
/** /**
* @type {Array<function(TestYInstance,prng.PRNG):void>} * @type {Array<function(Doc,prng.PRNG,any):void>}
*/ */
const arrayTransactions = [ const arrayTransactions = [
function insert (user, gen) { function insert (user, gen) {
@@ -291,12 +363,12 @@ const arrayTransactions = [
var length = yarray.length var length = yarray.length
if (length > 0) { if (length > 0) {
var somePos = prng.int31(gen, 0, length - 1) var somePos = prng.int31(gen, 0, length - 1)
var delLength = prng.int31(gen, 1, Math.min(2, length - somePos)) var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) { if (prng.bool(gen)) {
var type = yarray.get(somePos) var type = yarray.get(somePos)
if (type.length > 0) { if (type.length > 0) {
somePos = prng.int31(gen, 0, type.length - 1) somePos = prng.int31(gen, 0, type.length - 1)
delLength = prng.int31(gen, 0, Math.min(2, type.length - somePos)) delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength) type.delete(somePos, delLength)
} }
} else { } else {
@@ -309,8 +381,8 @@ const arrayTransactions = [
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
export const testRepeatGeneratingYarrayTests20 = tc => { export const testRepeatGeneratingYarrayTests4 = tc => {
applyRandomTests(tc, arrayTransactions, 3) applyRandomTests(tc, arrayTransactions, 4)
} }
/** /**

View File

@@ -1,4 +1,4 @@
import { init, compare, applyRandomTests, TestYInstance } from './testHelper.js' // eslint-disable-line import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import { import {
compareIDs compareIDs
@@ -19,6 +19,8 @@ export const testBasicMapTests = tc => {
map0.set('string', 'hello Y') map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } }) map0.set('object', { key: { key2: 'value' } })
map0.set('y-map', new Y.Map()) map0.set('y-map', new Y.Map())
map0.set('boolean1', true)
map0.set('boolean0', false)
const map = map0.get('y-map') const map = map0.get('y-map')
map.set('y-array', new Y.Array()) map.set('y-array', new Y.Array())
const array = map.get('y-array') const array = map.get('y-array')
@@ -27,6 +29,8 @@ export const testBasicMapTests = tc => {
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)') t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)') t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)') t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)') t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
@@ -35,12 +39,16 @@ export const testBasicMapTests = tc => {
t.assert(map1.get('number') === 1, 'client 1 received the update (number)') t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)') t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)') t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)') t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
// compare disconnected user // compare disconnected user
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected') t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected') t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')
t.assert(map2.get('boolean1') === true, 'client 2 computed the change (boolean)')
t.compare(map2.get('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected') t.compare(map2.get('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
t.assert(map2.get('y-map').get('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected') t.assert(map2.get('y-map').get('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
compare(users) compare(users)
@@ -58,7 +66,7 @@ export const testGetAndSetOfMapProperty = tc => {
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
const u = user.getMap('map') const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy') t.compare(u.get('stuff'), 'stuffy')
t.assert(u.get('undefined') === undefined, 'undefined') t.assert(u.get('undefined') === undefined, 'undefined')
@@ -100,7 +108,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
map0.set('stuff', 'stuffy') map0.set('stuff', 'stuffy')
t.compare(map0.get('stuff'), 'stuffy') t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
var u = user.getMap('map') var u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy') t.compare(u.get('stuff'), 'stuffy')
} }
@@ -115,7 +123,7 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
map0.set('stuff', 'c0') map0.set('stuff', 'c0')
map1.set('stuff', 'c1') map1.set('stuff', 'c1')
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
var u = user.getMap('map') var u = user.getMap('map')
t.compare(u.get('stuff'), 'c1') t.compare(u.get('stuff'), 'c1')
} }
@@ -131,7 +139,7 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
map1.set('stuff', 'c1') map1.set('stuff', 'c1')
map1.delete('stuff') map1.delete('stuff')
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
var u = user.getMap('map') var u = user.getMap('map')
t.assert(u.get('stuff') === undefined) t.assert(u.get('stuff') === undefined)
} }
@@ -148,7 +156,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
map1.set('stuff', 'c2') map1.set('stuff', 'c2')
map2.set('stuff', 'c3') map2.set('stuff', 'c3')
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
var u = user.getMap('map') var u = user.getMap('map')
t.compare(u.get('stuff'), 'c3') t.compare(u.get('stuff'), 'c3')
} }
@@ -171,7 +179,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
map3.set('stuff', 'c3') map3.set('stuff', 'c3')
map3.delete('stuff') map3.delete('stuff')
testConnector.flushAllMessages() testConnector.flushAllMessages()
for (let user of users) { for (const user of users) {
var u = user.getMap('map') var u = user.getMap('map')
t.assert(u.get('stuff') === undefined) t.assert(u.get('stuff') === undefined)
} }
@@ -284,6 +292,104 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testChangeEvent = tc => {
const { map0, users } = init(tc, { users: 2 })
/**
* @type {any}
*/
let changes = null
/**
* @type {any}
*/
let keyChange = null
map0.observe(e => {
changes = e.changes
})
map0.set('a', 1)
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
map0.set('a', 2)
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 1)
users[0].transact(() => {
map0.set('a', 3)
map0.set('a', 4)
})
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 2)
users[0].transact(() => {
map0.set('b', 1)
map0.set('b', 2)
})
keyChange = changes.keys.get('b')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
users[0].transact(() => {
map0.set('c', 1)
map0.delete('c')
})
t.assert(changes !== null && changes.keys.size === 0)
users[0].transact(() => {
map0.set('d', 1)
map0.set('d', 2)
})
keyChange = changes.keys.get('d')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
const doc = new Y.Doc()
const map = doc.getMap('map')
let updateCalled = false
let throwingObserverCalled = false
let throwingDeepObserverCalled = false
doc.on('update', () => {
updateCalled = true
})
const throwingObserver = () => {
throwingObserverCalled = true
throw new Error('Failure')
}
const throwingDeepObserver = () => {
throwingDeepObserverCalled = true
throw new Error('Failure')
}
map.observe(throwingObserver)
map.observeDeep(throwingDeepObserver)
t.fails(() => {
map.set('y', '2')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
// check if it works again
updateCalled = false
throwingObserverCalled = false
throwingDeepObserverCalled = false
t.fails(() => {
map.set('z', '3')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
t.assert(map.get('z') === '3')
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -320,16 +426,16 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
} }
/** /**
* @type {Array<function(TestYInstance,prng.PRNG):void>} * @type {Array<function(Doc,prng.PRNG):void>}
*/ */
const mapTransactions = [ const mapTransactions = [
function set (user, gen) { function set (user, gen) {
let key = prng.oneOf(gen, ['one', 'two']) const key = prng.oneOf(gen, ['one', 'two'])
var value = prng.utf16String(gen) var value = prng.utf16String(gen)
user.getMap('map').set(key, value) user.getMap('map').set(key, value)
}, },
function setType (user, gen) { function setType (user, gen) {
let key = prng.oneOf(gen, ['one', 'two']) const key = prng.oneOf(gen, ['one', 'two'])
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()]) var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
user.getMap('map').set(key, type) user.getMap('map').set(key, type)
if (type instanceof Y.Array) { if (type instanceof Y.Array) {
@@ -339,7 +445,7 @@ const mapTransactions = [
} }
}, },
function _delete (user, gen) { function _delete (user, gen) {
let key = prng.oneOf(gen, ['one', 'two']) const key = prng.oneOf(gen, ['one', 'two'])
user.getMap('map').delete(key) user.getMap('map').delete(key)
} }
] ]

View File

@@ -1,6 +1,6 @@
import { init, compare } from './testHelper.js' import * as Y from './testHelper.js'
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
const { init, compare } = Y
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
@@ -27,6 +27,13 @@ export const testBasicInsertAndDelete = tc => {
text0.delete(1, 1) text0.delete(1, 1)
t.assert(text0.toString() === 'b', 'Basic delete works (position 1)') t.assert(text0.toString() === 'b', 'Basic delete works (position 1)')
t.compare(delta, [{ retain: 1 }, { delete: 1 }]) t.compare(delta, [{ retain: 1 }, { delete: 1 }])
users[0].transact(() => {
text0.insert(0, '1')
text0.delete(0, 1)
})
t.compare(delta, [])
compare(users) compare(users)
} }
@@ -56,7 +63,7 @@ export const testBasicFormat = tc => {
t.compare(text0.toDelta(), [{ insert: 'zb', attributes: { bold: true } }]) t.compare(text0.toDelta(), [{ insert: 'zb', attributes: { bold: true } }])
t.compare(delta, [{ insert: 'z', attributes: { bold: true } }]) t.compare(delta, [{ insert: 'z', attributes: { bold: true } }])
// @ts-ignore // @ts-ignore
t.assert(text0._start.right.right.right.string === 'b', 'Does not insert duplicate attribute marker') t.assert(text0._start.right.right.right.content.str === 'b', 'Does not insert duplicate attribute marker')
text0.insert(0, 'y') text0.insert(0, 'y')
t.assert(text0.toString() === 'yzb') t.assert(text0.toString() === 'yzb')
t.compare(text0.toDelta(), [{ insert: 'y' }, { insert: 'zb', attributes: { bold: true } }]) t.compare(text0.toDelta(), [{ insert: 'y' }, { insert: 'zb', attributes: { bold: true } }])
@@ -67,3 +74,87 @@ export const testBasicFormat = tc => {
t.compare(delta, [{ retain: 1 }, { retain: 1, attributes: { bold: null } }]) t.compare(delta, [{ retain: 1 }, { retain: 1, attributes: { bold: null } }])
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testGetDeltaWithEmbeds = tc => {
const { text0 } = init(tc, { users: 1 })
text0.applyDelta([{
insert: { linebreak: 's' }
}])
t.compare(text0.toDelta(), [{
insert: { linebreak: 's' }
}])
}
/**
* @param {t.TestCase} tc
*/
export const testSnapshot = tc => {
const { text0 } = init(tc, { users: 1 })
const doc0 = /** @type {Y.Doc} */ (text0.doc)
doc0.gc = false
text0.applyDelta([{
insert: 'abcd'
}])
const snapshot1 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 1
}, {
insert: 'x'
}, {
delete: 1
}])
const snapshot2 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 2
}, {
delete: 3
}, {
insert: 'x'
}, {
delete: 1
}])
const state1 = text0.toDelta(snapshot1)
t.compare(state1, [{ insert: 'abcd' }])
const state2 = text0.toDelta(snapshot2)
t.compare(state2, [{ insert: 'axcd' }])
const state2Diff = text0.toDelta(snapshot2, snapshot1)
// @ts-ignore Remove userid info
state2Diff.forEach(v => {
if (v.attributes && v.attributes.ychange) {
delete v.attributes.ychange.user
}
})
t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }])
}
/**
* @param {t.TestCase} tc
*/
export const testSnapshotDeleteAfter = tc => {
const { text0 } = init(tc, { users: 1 })
const doc0 = /** @type {Y.Doc} */ (text0.doc)
doc0.gc = false
text0.applyDelta([{
insert: 'abcd'
}])
const snapshot1 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 4
}, {
insert: 'e'
}])
const state1 = text0.toDelta(snapshot1)
t.compare(state1, [{ insert: 'abcd' }])
}
/**
* @param {t.TestCase} tc
*/
export const testToJson = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, 'abc', { bold: true })
t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text')
}

View File

@@ -60,13 +60,13 @@ export const testEvents = tc => {
*/ */
export const testTreewalker = tc => { export const testTreewalker = tc => {
const { users, xml0 } = init(tc, { users: 3 }) const { users, xml0 } = init(tc, { users: 3 })
let paragraph1 = new Y.XmlElement('p') const paragraph1 = new Y.XmlElement('p')
let paragraph2 = new Y.XmlElement('p') const paragraph2 = new Y.XmlElement('p')
let text1 = new Y.XmlText('init') const text1 = new Y.XmlText('init')
let text2 = new Y.XmlText('text') const text2 = new Y.XmlText('text')
paragraph1.insert(0, [text1, text2]) paragraph1.insert(0, [text1, text2])
xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')]) xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')])
let allParagraphs = xml0.querySelectorAll('p') const allParagraphs = xml0.querySelectorAll('p')
t.assert(allParagraphs.length === 2, 'found exactly two paragraphs') t.assert(allParagraphs.length === 2, 'found exactly two paragraphs')
t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1') t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1')
t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2') t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2')

View File

@@ -6,15 +6,15 @@
"allowJs": true, /* Allow javascript files to be compiled. */ "allowJs": true, /* Allow javascript files to be compiled. */
"checkJs": true, /* Report errors in .js files. */ "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */ "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */ // "outFile": "./dist/yjs.js", /* Concatenate and emit output to single file. */
// "outDir": "./build", /* Redirect output structure to the directory. */ "outDir": "./dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */ // "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */ // "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */ // "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
@@ -22,6 +22,7 @@
/* Strict Type-Checking Options */ /* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */ "strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"emitDeclarationOnly": true,
// "strictNullChecks": true, /* Enable strict null checks. */ // "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
@@ -36,8 +37,13 @@
/* Module Resolution Options */ /* Module Resolution Options */
"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": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ "paths": {
"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'. */
// "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. */
// "types": [], /* Type declaration files to be included in compilation. */ // "types": [], /* Type declaration files to be included in compilation. */
@@ -57,6 +63,6 @@
"maxNodeModuleJsDepth": 5, "maxNodeModuleJsDepth": 5,
// "types": ["./src/utils/typedefs.js"] // "types": ["./src/utils/typedefs.js"]
}, },
"include": ["./src/**/*", "./tests/**/*"], "include": ["./src/**/*.js", "./tests/**/*.js"],
"exclude": ["../lib0/**/*", "node_modules"] "exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
} }