Compare commits

..

175 Commits

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

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

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

View File

@@ -17,7 +17,7 @@
"useCollapsibles": true,
"collapse": true,
"resources": {
"y-js.org": "yjs.website"
"yjs.dev": "Yjs website"
},
"logo": {
"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
}

1194
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,559 +0,0 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
> A CRDT framework with a powerful abstraction of shared data
Yjs is a [CRDT implementation](#Yjs-CRDT-Algorithm) that exposes its internal data structure 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**, **undo/redo** and **shared cursors**. It scales well with an unlimited number of users and is well suited for even large documents.
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
* Demos: [https://github.com/y-js/yjs-demos](https://github.com/y-js/yjs-demos)
* Benchmarks: [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
# Table of Contents
* [Overview](#Overview)
* [Bindings](#Bindings)
* [Providers](#Providers)
* [Getting Started](#Getting-Started)
* [API](#API)
* [Shared Types](#Shared-Types)
* [Y.Doc](#Y.Doc)
* [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions)
* [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
* [Evaluation](#Evaluation)
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
* [CRDT Algorithms](#CRDT-Algorithms)
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
* [License and Author](#License-and-Author)
## Overview
This repository contains a collection of shared types that can be observed for changes and manipulated concurrently. Network functionality and two-way-bindings are implemented in separate modules.
### Bindings
| Name                                                   | Cursors | Binding | Demo |
|---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) |
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [demo](https://yjs-demos.now.sh/quill/) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/y-js/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/y-js/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/y-js/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
### 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 the perfect starting point for your collaborative app.
<dl>
<dt><a href="http://github.com/y-js/y-websocket">y-websocket</a></dt>
<dd>A module that contains a simple websocket backend and a websocket client that connects to that backend. The backend can be extended to persist updates in a leveldb database.</dd>
<dt><a href="http://github.com/y-js/y-mesh">y-mesh</a></dt>
<dd>[WIP] Creates a connected graph of webrtc connections with a high <a href="https://en.wikipedia.org/wiki/Strength_of_a_graph">strength</a>. It requires a signalling server that connects a client to the first peer. But after that the network manages itself. It is well suited for large and small networks.</dd>
<dt><a href="http://github.com/y-js/y-dat">y-dat</a></dt>
<dd>[WIP] Write document updates effinciently to the dat network using <a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has an append-only log of CRDT local updates (hypercore). Multifeed manages and sync hypercores and y-dat listens to changes and applies them to the Yjs document.</dd>
</dl>
## Getting Started
Install Yjs and a provider with your favorite package manager.
```sh
npm i yjs@13.0.0-82 y-websocket@1.0.0-3 y-textarea
```
**Start the y-websocket server**
```sh
PORT=1234 node ./node_modules/y-websocket/bin/server.js
```
**Example: Textarea Binding**
This is a complete example on how to create a connection to a [y-websocket](https://github.com/y-js/y-websocket) server instance, sync the shared document to all clients in a *room*, and bind a Y.Text type to a dom textarea. All changes to the textarea are automatically shared with everyone in the same room.
```js
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { TextareaBinding } from 'y-textarea'
const doc = Y.Doc()
const provider = new WebsocketProvider('http://localhost:1234', 'roomname')
// sync all document updates through the websocket connection
provider.sync('doc')
// Define a shared type on the document.
const ytext = doc.getText('my resume')
// use data bindings to bind types to editors
const binding = new TextareaBinding(ytext, document.querySelector('textarea'))
```
**Example: Observe types**
```js
const yarray = doc.getArray('my-array')
yarray.observe(event => {
console.log('yarray was modified')
})
// every time a local or remote client modifies yarray, the observer is called
yarray.insert(0, ['val']) // => "yarray was modified"
```
**Example: Nest types**
Remember, shared types are just plain old data types. The only limitation is that a shared type must exist only once in the shared document.
```js
const ymap = doc.getMap('map')
const foodArray = new Y.Array()
foodArray.insert(0, ['apple', 'banana'])
ymap.set('food', foodArray)
ymap.get('food') === foodArray // => true
ymap.set('fruit', foodArray) // => Error! foodArray is already defined on the shared document
```
Now you understand how types are defined on a shared document. Next you can jump to the [demo repository](https://github.com/y-js/yjs-demos) or continue reading the API docs.
## API
```js
import * as Y from 'yjs'
```
### Shared Types
<details>
<summary><b>Y.Array</b></summary>
<br>
<p>
A shareable Array-like type that supports efficient insert/delete of elements at any position. Internally it uses a linked list of Arrays that is split when necessary.
</p>
<pre>const yarray = new Y.Array()</pre>
<dl>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd>
Insert content at <var>index</var>. Note that content is an array of elements. I.e. <code>array.insert(0, [1]</code> splices the list and inserts 1 at position 0.
</dd>
<b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<dd>Copies the content of this YArray to a new Array.</dd>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number&gt;</code></b>
<dd>Copies the content of this YArray to a new Array. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
Returns an YArray Iterator that contains the values for each index in the array.
<pre>for (let value of yarray) { .. }</pre>
</dd>
<b><code>observe(function(YArrayEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YArrayEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.Map</b></summary>
<br>
<p>
A shareable Map type.
</p>
<pre><code>const ymap = new Y.Map()</code></pre>
<dl>
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
<dd></dd>
<b><code>delete(key:string)</code></b>
<dd></dd>
<b><code>has(key:string):boolean</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number&gt;</code></b>
<dd>Copies the <code>[key,value]</code> pairs of this YMap to a new Object. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
Returns an Iterator of <code>[key, value]</code> pairs.
<pre>for (let [key, value] of ymap) { .. }</pre>
</dd>
<b><code>entries()</code></b>
<dd>
Returns an Iterator of <code>[key, value]</code> pairs.
</dd>
<b><code>values()</code></b>
<dd>
Returns an Iterator of all values.
</dd>
<b><code>keys()</code></b>
<dd>
Returns an Iterator of all keys.
</dd>
<b><code>observe(function(YMapEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YMapEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.Text</b></summary>
<br>
<p>
A shareable type that is optimized for shared editing on text. It allows to assign properties to ranges in the text. This makes it possible to implement rich-text bindings to this type.
</p>
<p>
This type can also be transformed to the <a href="https://quilljs.com/docs/delta">delta format</a>. Similarly the YTextEvents compute changes as deltas.
</p>
<pre>const ytext = new Y.Text()</pre>
<dl>
<b><code>insert(index:number, content:string, [formattingAttributes:Object&lt;string,string&gt;])</code></b>
<dd>
Insert a string at <var>index</var> and assign formatting attributes to it.
<pre>ytext.insert(0, 'bold text', { bold: true })</pre>
</dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b>
<dd>Assign formatting attributes to a range in the text</dd>
<b><code>applyDelta(delta)</code></b>
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>toString():string</code></b>
<dd>Transforms this type, without formatting options, into a string.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code></dd>
<b><code>toDelta():Delta</code></b>
<dd>Transforms this type to a <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
<b><code>observe(function(YTextEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YTextEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>YXmlFragment</b></summary>
<br>
<p>
A container that holds an Array of Y.XmlElements.
</p>
<pre><code>const yxml = new Y.XmlFragment()</code></pre>
<dl>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():DocumentFragment</code></b>
<dd>Transforms this type and all children to new DOM elements.</dd>
<b><code>toString():string</code></b>
<dd>Get the XML serialization of all descendants.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code>.</dd>
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.XmlElement</b></summary>
<br>
<p>
A shareable type that represents an XML Element. It has a <code>nodeName</code>, attributes, and a list of children. But it makes no effort to validate its content and be actually XML compliant.
</p>
<pre><code>const yxml = new Y.XmlElement()</code></pre>
<dl>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>setAttribute(attributeName:string, attributeValue:string)</code></b>
<dd></dd>
<b><code>removeAttribute(attributeName:string)</code></b>
<dd></dd>
<b><code>getAttribute(attributeName:string):string</code></b>
<dd></dd>
<b><code>getAttributes(attributeName:string):Object&lt;string,string&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():Element</code></b>
<dd>Transforms this type and all children to a new DOM element.</dd>
<b><code>toString():string</code></b>
<dd>Get the XML serialization of all descendants.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code>.</dd>
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
### Y.Doc
```js
const doc = new Y.Doc()
```
<dl>
<b><code>clientID</code></b>
<dd>A unique id that identifies this client. (readonly)</dd>
<b><code>transact(function(Transaction):void [, origin:any])</code></b>
<dd>Every change on the shared document happens in a transaction. Observer calls and the <code>update</code> event are called after each transaction. You should <i>bundle</i> changes into a single transaction to reduce the amount of event calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code> triggers a single change event. <br>You can specify an optional <code>origin</code> parameter that is stored on <code>transaction.origin</code> and <code>on('update', (update, origin) => ..)</code>.</dd>
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
<dd>Define a shared type.</dd>
<b><code>getArray(string):Y.Array</code></b>
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
<b><code>getMap(string):Y.Map</code></b>
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
<b><code>getXmlFragment(string):Y.XmlFragment</code></b>
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
<b><code>on(string, function)</code></b>
<dd>Register an event listener on the shared type</dd>
<b><code>off(string, function)</code></b>
<dd>Unregister an event listener from the shared type</dd>
</dl>
#### Y.Doc Events
<dl>
<b><code>on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)</code></b>
<dd>Listen to document updates. Document updates must be transmitted to all other peers. You can apply document updates in any order and multiple times.</dd>
<b><code>on('beforeTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
<dd>Emitted before each transaction.</dd>
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
<dd>Emitted after each transaction.</dd>
</dl>
### Document Updates
Changes on the shared document are encoded into *document updates*. Document updates are *commutative* and *idempotent*. This means that they can be applied in any order and multiple times.
**Example: Listen to update events and apply them on remote client**
```js
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
doc1.on('update', update => {
Y.applyUpdate(doc2, update)
})
doc2.on('update', update => {
Y.applyUpdate(doc1, update)
})
// All changes are also applied to the other document
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'
```
Yjs internally maintains a [state vector](#State-Vector) that denotes the next expected clock from each client. In a different interpretation it holds the number of structs created by each client. When two clients sync, you can either exchange the complete document structure or only the differences by sending the state vector to compute the differences.
**Example: Sync two clients by exchanging the complete document structure**
```js
const state1 = Y.encodeStateAsUpdate(ydoc1)
const state2 = Y.encodeStateAsUpdate(ydoc2)
Y.applyUpdate(ydoc1, state2)
Y.applyUpdate(ydoc2, state1)
```
**Example: Sync two clients by computing the differences**
This example shows how to sync two clients with the minimal amount of exchanged data by computing only the differences using the state vector of the remote client. Syncing clients using the state vector requires another roundtrip, but can safe a lot of bandwidth.
```js
const stateVector1 = Y.encodeStateVector(ydoc1)
const stateVector2 = Y.encodeStateVector(ydoc2)
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
```
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>Apply a document update on the shared document. Optionally you can specify <code>transactionOrigin</code> that will be stored on <code>transaction.origin</code> and <code>ydoc.on('update', (update, origin) => ..)</code>.</dd>
<b><code>Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array</code></b>
<dd>Encode the document state as a single update message that can be applied on the remote document. Optionally specify the target state vector to only write the differences to the update message.</dd>
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
</dl>
### Relative Positions
> This API is not stable yet
This feature is intended for managing selections / cursors. When working with other users that manipulate the shared document, you can't trust that an index position (an integer) will stay at the intended location. A *relative position* is fixated to an element in the shared document and is not affected by remote changes. I.e. given the document `"a|c"`, the relative position is attached to `c`. When a remote user modifies the document by inserting a character before the cursor, the cursor will stay attached to the character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the document, it will stay attached to the end of the document.
**Example: Transform to RelativePosition and back**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
pos.type === ytext // => true
pos.index === 2 // => true
```
**Example: Send relative position to remote client (json)**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = JSON.stringify(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = JSON.parse(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
**Example: Send relative position to remote client (Uint8Array)**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = Y.encodeRelativePosition(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
<dl>
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
<dd></dd>
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
<dd></dd>
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
<dd></dd>
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
<dd></dd>
</dl>
## Miscellaneous
### Typescript Declarations
Yjs has type descriptions. But until [this ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is how you can make use of Yjs type declarations.
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
},
"maxNodeModuleJsDepth": 5
}
```
## Yjs CRDT Algorithm
*Conflict-free replicated data types* (CRDT) for collaborative editing are an alternative approach to *operational transformation* (OT). A very simple differenciation between the two approaches is that OT attempts to transform index positions to ensure convergence (all clients end up with the same content), while CRDTs use mathematical models that usually do not involve index transformations, like linked lists. OT is currently the de-facto standard for shared editing on text. OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice. CRDTs are better suited for distributed systems, provide additional guarantees that the document can be synced with remote clients, and do not require a central source of truth.
Yjs implements a modified version of the algorithm described in [this paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types). I will eventually publish a paper that describes why this approach works so well in practice. Note: Since operations make up the document structure, we prefer the term *struct* now.
CRDTs suitable for shared text editing suffer from the fact that they only grow in size. There are CRDTs that do not grow in size, but they do not have the characteristics that are benificial for shared text editing (like intention preservation). Yjs implements many improvements to the original algorithm that diminish the trade-off that the document only grows in size. We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs. But we can 1. merge preceeding structs into a single struct to reduce the amount of meta information, 2. we can delete content from the struct if it is deleted, and 3. we can garbage collect tombstones if we don't care about the order of the structs anymore (e.g. if the parent was deleted).
**Examples:**
1. If a user inserts elements in sequence, the struct will be merged into a single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['b']);` is first represented as two structs (`[{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}`) and then merged into a single struct: `[{id: {client, clock: 0}, content: 'ab'}]`.
2. When a struct that contains content (e.g. `ItemString`) is deleted, the struct will be replaced with an `ItemDeleted` that does not contain content anymore.
3. When a type is deleted, all child elements are transformed to `GC` structs. A `GC` struct only denotes the existence of a struct and that it is deleted. `GC` structs can always be merged with other `GC` structs if the id's are adjacent.
Especially when working on structured content (e.g. shared editing on ProseMirror), these improvements yield very good results when [benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits. In practice they show even better results, because users usually edit text in sequence, resulting in structs that can easily be merged. The benchmarks show that even in the worst case scenario that a user edits text from right to left, Yjs achieves good performance even for huge documents.
#### State Vector
Yjs has the ability to exchange only the differences when syncing two clients. We use lamport timestamps to identify structs and to track in which order a client created them. Each struct has an `struct.id = { client: number, clock: number}` that uniquely identifies a struct. We define the next expected `clock` by each client as the *state vector*. This data structure is similar to the [version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure. But we use state vectors only to describe the state of the local document, so we can compute the missing struct of the remote client. We do not use it to track causality.
## License and Author
Yjs and all related projects are [**MIT licensed**](./LICENSE).
Yjs is based on my research as a student at the [RWTH i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or hiring [me](https://github.com/dmonad) for professional support.

3995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,27 @@
{
"name": "yjs",
"version": "13.0.0-87",
"version": "13.2.0",
"description": "Shared Editing Library",
"main": "./dist/yjs.js",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"scripts": {
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
"dist": "rm -rf dist && rollup -c",
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
"dist": "rm -rf dist && rollup -c && tsc",
"watch": "rollup -wc",
"lint": "standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
"serve-docs": "npm run docs && serve ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
"postversion": "git push && git push --tags",
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && http-server ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000",
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
},
"files": [
"dist/*",
@@ -38,31 +42,38 @@
},
"repository": {
"type": "git",
"url": "https://github.com/y-js/yjs.git"
"url": "https://github.com/yjs/yjs.git"
},
"keywords": [
"crdt"
"Yjs",
"CRDT",
"offline",
"shared editing",
"concurrency",
"collaboration"
],
"author": "Kevin Jahns",
"email": "kevin.jahns@rwth-aachen.de",
"email": "kevin.jahns@protonmail.com",
"license": "MIT",
"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": {
"lib0": "0.0.5"
"lib0": "^0.2.27"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.1.3",
"concurrently": "^3.6.1",
"jsdoc": "^3.6.2",
"live-server": "^1.2.1",
"rollup": "^1.11.3",
"http-server": "^0.12.3",
"jsdoc": "^3.6.4",
"markdownlint-cli": "^0.23.1",
"rollup": "^1.32.1",
"rollup-cli": "^1.0.9",
"rollup-plugin-node-resolve": "^4.2.4",
"standard": "^11.0.1",
"standard": "^14.3.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^3.4.5",
"y-protocols": "0.0.6"
"typescript": "^3.9.3",
"y-protocols": "^0.2.3"
}
}

View File

@@ -1,4 +1,5 @@
import nodeResolve from 'rollup-plugin-node-resolve'
import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
const localImports = process.env.LOCALIMPORTS
@@ -37,23 +38,27 @@ const debugResolve = {
export default [{
input: './src/index.js',
output: [{
output: {
name: 'Y',
file: 'dist/yjs.js',
file: 'dist/yjs.cjs',
format: 'cjs',
sourcemap: true,
paths: path => {
if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5)}`
return `lib0/dist/${path.slice(5, -3)}.cjs`
}
return path
}
}, {
},
external: id => /^lib0\//.test(id)
}, {
input: './src/index.js',
output: {
name: 'Y',
file: 'dist/yjs.mjs',
format: 'es',
format: 'esm',
sourcemap: true
}],
},
external: id => /^lib0\//.test(id)
}, {
input: './tests/index.js',
@@ -66,8 +71,24 @@ export default [{
plugins: [
debugResolve,
nodeResolve({
sourcemap: true,
mainFields: ['module', 'browser', 'main']
})
}),
commonjs()
]
}, {
input: './tests/index.js',
output: {
name: 'test',
file: 'dist/tests.cjs',
format: 'cjs',
sourcemap: true
},
plugins: [
debugResolve,
nodeResolve({
mainFields: ['module', 'main']
}),
commonjs()
],
external: ['isomorphic.js']
}]

View File

@@ -21,10 +21,12 @@ export {
ContentEmbed,
ContentFormat,
ContentJSON,
ContentAny,
ContentString,
ContentType,
AbstractType,
RelativePosition,
getTypeChildren,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
@@ -36,11 +38,26 @@ export {
compareIDs,
getState,
Snapshot,
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
snapshot,
emptySnapshot,
findRootTypeKey,
typeListToArraySnapshot,
typeMapGetSnapshot,
iterateDeletedStructs,
applyUpdate,
readUpdate,
encodeStateAsUpdate,
encodeStateVector
encodeStateVector,
UndoManager,
decodeSnapshot,
encodeSnapshot,
isDeleted,
isParentOf,
equalSnapshots,
PermanentUserData, // @TODO experimental
tryGc,
transact
} from './internals.js'

View File

@@ -1,13 +1,16 @@
export * from './utils/DeleteSet.js'
export * from './utils/Doc.js'
export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js'
export * from './utils/StructStore.js'
export * from './utils/Transaction.js'
// export * from './utils/UndoManager.js'
export * from './utils/Doc.js'
export * from './utils/UndoManager.js'
export * from './utils/YEvent.js'
export * from './types/AbstractType.js'
@@ -27,8 +30,7 @@ export * from './structs/ContentDeleted.js'
export * from './structs/ContentEmbed.js'
export * from './structs/ContentFormat.js'
export * from './structs/ContentJSON.js'
export * from './structs/ContentAny.js'
export * from './structs/ContentString.js'
export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './utils/encoding.js'

View File

@@ -6,24 +6,17 @@ import {
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as error from 'lib0/error.js'
/**
* @private
*/
export class AbstractStruct {
/**
* @param {ID} id
* @param {number} length
*/
constructor (id, length) {
/**
* The uniqe identifier of this struct.
* @type {ID}
* @readonly
*/
this.id = id
this.length = length
this.deleted = false
}
/**
* Merge this struct with the item to the right.
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
@@ -34,55 +27,21 @@ export class AbstractStruct {
mergeWith (right) {
return false
}
/**
* @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset
* @param {number} encodingRef
* @private
*/
write (encoder, offset, encodingRef) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
*/
integrate (transaction) {
throw error.methodUnimplemented()
}
}
/**
* @private
*/
export class AbstractStructRef {
/**
* @param {ID} id
*/
constructor (id) {
/**
* @type {Array<ID>}
*/
this._missing = []
/**
* The uniqe identifier of this type.
* @type {ID}
*/
this.id = id
}
/**
* @param {Transaction} transaction
* @return {Array<ID|null>}
*/
getMissing (transaction) {
return this._missing
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {AbstractStruct}
*/
toStruct (transaction, store, offset) {
integrate (transaction, offset) {
throw error.methodUnimplemented()
}
}

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

@@ -0,0 +1,111 @@
import {
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
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
}
}
/**
* @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

@@ -7,9 +7,6 @@ import * as decoding from 'lib0/decoding.js'
import * as buffer from 'lib0/buffer.js'
import * as error from 'lib0/error.js'
/**
* @private
*/
export class ContentBinary {
/**
* @param {Uint8Array} content
@@ -17,30 +14,35 @@ export class ContentBinary {
constructor (content) {
this.content = content
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.content]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentBinary}
*/
copy () {
return new ContentBinary(this.content)
}
/**
* @param {number} offset
* @return {ContentBinary}
@@ -48,6 +50,7 @@ export class ContentBinary {
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentBinary} right
* @return {boolean}
@@ -55,6 +58,7 @@ export class ContentBinary {
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -75,6 +79,7 @@ export class ContentBinary {
write (encoder, offset) {
encoding.writeVarUint8Array(encoder, this.content)
}
/**
* @return {number}
*/
@@ -84,8 +89,6 @@ export class ContentBinary {
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentBinary}
*/

View File

@@ -7,9 +7,6 @@ import {
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentDeleted {
/**
* @param {number} len
@@ -17,30 +14,35 @@ export class ContentDeleted {
constructor (len) {
this.len = len
}
/**
* @return {number}
*/
getLength () {
return this.len
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentDeleted}
*/
copy () {
return new ContentDeleted(this.len)
}
/**
* @param {number} offset
* @return {ContentDeleted}
@@ -50,6 +52,7 @@ export class ContentDeleted {
this.len = offset
return right
}
/**
* @param {ContentDeleted} right
* @return {boolean}
@@ -58,6 +61,7 @@ export class ContentDeleted {
this.len += right.len
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -66,6 +70,7 @@ export class ContentDeleted {
addToDeleteSet(transaction.deleteSet, item.id, this.len)
item.deleted = true
}
/**
* @param {Transaction} transaction
*/
@@ -81,6 +86,7 @@ export class ContentDeleted {
write (encoder, offset) {
encoding.writeVarUint(encoder, this.len - offset)
}
/**
* @return {number}
*/

View File

@@ -17,30 +17,35 @@ export class ContentEmbed {
constructor (embed) {
this.embed = embed
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.embed]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentEmbed}
*/
copy () {
return new ContentEmbed(this.embed)
}
/**
* @param {number} offset
* @return {ContentEmbed}
@@ -48,6 +53,7 @@ export class ContentEmbed {
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentEmbed} right
* @return {boolean}
@@ -55,6 +61,7 @@ export class ContentEmbed {
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -75,6 +82,7 @@ export class ContentEmbed {
write (encoder, offset) {
encoding.writeVarString(encoder, JSON.stringify(this.embed))
}
/**
* @return {number}
*/

View File

@@ -19,30 +19,35 @@ export class ContentFormat {
this.key = key
this.value = value
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentFormat}
*/
copy () {
return new ContentFormat(this.key, this.value)
}
/**
* @param {number} offset
* @return {ContentFormat}
@@ -50,6 +55,7 @@ export class ContentFormat {
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentFormat} right
* @return {boolean}
@@ -57,6 +63,7 @@ export class ContentFormat {
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -78,6 +85,7 @@ export class ContentFormat {
encoding.writeVarString(encoder, this.key)
encoding.writeVarString(encoder, JSON.stringify(this.value))
}
/**
* @return {number}
*/
@@ -87,8 +95,6 @@ export class ContentFormat {
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentFormat}
*/

View File

@@ -18,30 +18,35 @@ export class ContentJSON {
*/
this.arr = arr
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentJSON}
*/
copy () {
return new ContentJSON(this.arr)
}
/**
* @param {number} offset
* @return {ContentJSON}
@@ -51,6 +56,7 @@ export class ContentJSON {
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentJSON} right
* @return {boolean}
@@ -59,6 +65,7 @@ export class ContentJSON {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -84,6 +91,7 @@ export class ContentJSON {
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
}
}
/**
* @return {number}
*/

View File

@@ -18,30 +18,35 @@ export class ContentString {
*/
this.str = str
}
/**
* @return {number}
*/
getLength () {
return this.str.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.str.split('')
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentString}
*/
copy () {
return new ContentString(this.str)
}
/**
* @param {number} offset
* @return {ContentString}
@@ -51,6 +56,7 @@ export class ContentString {
this.str = this.str.slice(0, offset)
return right
}
/**
* @param {ContentString} right
* @return {boolean}
@@ -59,6 +65,7 @@ export class ContentString {
this.str += right.str
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -79,6 +86,7 @@ export class ContentString {
write (encoder, offset) {
encoding.writeVarString(encoder, offset === 0 ? this.str : this.str.slice(offset))
}
/**
* @return {number}
*/

View File

@@ -7,7 +7,7 @@ import {
readYXmlFragment,
readYXmlHook,
readYXmlText,
StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
ID, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
@@ -44,32 +44,40 @@ 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}
@@ -77,6 +85,7 @@ export class ContentType {
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentType} right
* @return {boolean}
@@ -84,6 +93,7 @@ export class ContentType {
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -91,6 +101,7 @@ export class ContentType {
integrate (transaction, item) {
this.type._integrate(transaction.doc, item)
}
/**
* @param {Transaction} transaction
*/
@@ -104,7 +115,7 @@ export class ContentType {
// We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
transaction._mergeStructs.add(item.id)
transaction._mergeStructs.push(item)
}
item = item.right
}
@@ -113,12 +124,12 @@ export class ContentType {
item.delete(transaction)
} else {
// same as above
transaction._mergeStructs.add(item.id)
transaction._mergeStructs.push(item)
}
})
transaction.changed.delete(this.type)
transaction.changedParentTypes.delete(this.type)
}
/**
* @param {StructStore} store
*/
@@ -137,6 +148,7 @@ export class ContentType {
})
this.type._map = new Map()
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
@@ -144,6 +156,7 @@ export class ContentType {
write (encoder, offset) {
this.type._write(encoder)
}
/**
* @return {number}
*/

View File

@@ -1,13 +1,10 @@
import {
AbstractStructRef,
AbstractStruct,
createID,
addStruct,
StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
export const structGCRefNumber = 0
@@ -38,8 +35,13 @@ export class GC extends AbstractStruct {
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction) {
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.length -= offset
}
addStruct(transaction.doc.store, this)
}
@@ -51,39 +53,13 @@ export class GC extends AbstractStruct {
encoding.writeUint8(encoder, structGCRefNumber)
encoding.writeVarUint(encoder, this.length - offset)
}
}
/**
* @private
*/
export class GCRef extends AbstractStructRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(id)
/**
* @type {number}
*/
this.length = decoding.readVarUint(decoder)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {GC}
* @return {null | ID}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
// @ts-ignore
this.id = createID(this.id.client, this.id.clock + offset)
this.length -= offset
}
return new GC(
this.id,
this.length
)
getMissing (transaction, store) {
return null
}
}

View File

@@ -1,11 +1,9 @@
import {
readID,
createID,
writeID,
GC,
nextID,
AbstractStructRef,
getState,
AbstractStruct,
replaceStruct,
addStruct,
@@ -18,11 +16,14 @@ import {
readContentDeleted,
readContentBinary,
readContentJSON,
readContentAny,
readContentString,
readContentEmbed,
createID,
readContentFormat,
readContentType,
ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
addChangedTypeToTransaction,
Doc, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
@@ -32,6 +33,49 @@ import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
* @todo This should return several items
*
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
*/
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 = /** @type {AbstractType<any>} */ (item.parent)._item
}
}
/**
* Split leftItem into two items
* @param {Transaction} transaction
@@ -43,12 +87,12 @@ import * as binary from 'lib0/binary.js'
* @private
*/
export const splitItem = (transaction, leftItem, diff) => {
const id = leftItem.id
// create rightItem
const { client, clock } = leftItem.id
const rightItem = new Item(
createID(id.client, id.clock + diff),
createID(client, clock + diff),
leftItem,
createID(id.client, id.clock + diff - 1),
createID(client, clock + diff - 1),
leftItem.right,
leftItem.rightOrigin,
leftItem.parent,
@@ -58,6 +102,12 @@ export const splitItem = (transaction, leftItem, diff) => {
if (leftItem.deleted) {
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)
leftItem.right = rightItem
// update right
@@ -65,15 +115,122 @@ export const splitItem = (transaction, leftItem, diff) => {
rightItem.right.left = rightItem
}
// right is more specific.
transaction._mergeStructs.add(rightItem.id)
transaction._mergeStructs.push(rightItem)
// update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) {
rightItem.parent._map.set(rightItem.parentSub, rightItem)
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
return rightItem
}
/**
* 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) => {
const doc = transaction.doc
const store = doc.store
const ownClientID = doc.clientID
const redone = item.redone
if (redone !== null) {
return getItemCleanStart(transaction, redone)
}
let parentItem = /** @type {AbstractType<any>} */ (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 !== ownClientID) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
}
if (left.right !== null) {
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
}
right = null
}
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === 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 && /** @type {AbstractType<any>} */ (leftTrace.parent)._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && /** @type {AbstractType<any>} */ (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 && /** @type {AbstractType<any>} */ (rightTrace.parent)._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item === parentItem) {
right = rightTrace
break
}
right = right.right
}
}
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)
const redoneItem = new Item(
nextId,
left, left && left.lastId,
right, right && right.id,
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
item.parentSub,
item.content.copy()
)
item.redone = nextId
keepItem(redoneItem, true)
redoneItem.integrate(transaction, 0)
return redoneItem
}
/**
* Abstract class that represents any content.
*/
@@ -84,7 +241,7 @@ export class Item extends AbstractStruct {
* @param {ID | null} origin
* @param {Item | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {AbstractType<any>|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it.
* @param {string | null} parentSub
* @param {AbstractContent} content
*/
@@ -93,7 +250,6 @@ export class Item extends AbstractStruct {
/**
* The item that was originally to the left of this item.
* @type {ID | null}
* @readonly
*/
this.origin = origin
/**
@@ -108,14 +264,11 @@ export class Item extends AbstractStruct {
this.right = right
/**
* The item that was originally to the right of this item.
* @readonly
* @type {ID | null}
*/
this.rightOrigin = rightOrigin
/**
* The parent type.
* @type {AbstractType<any>}
* @readonly
* @type {AbstractType<any>|ID|null}
*/
this.parent = parent
/**
@@ -124,7 +277,6 @@ export class Item extends AbstractStruct {
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
* @readonly
*/
this.parentSub = parentSub
/**
@@ -135,115 +287,201 @@ export class Item extends AbstractStruct {
/**
* If this type's effect is reundone this type refers to the type that undid
* this operation.
* @type {Item | null}
* @type {ID | 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
}
get countable () {
return this.content.isCountable()
}
/**
* Return missing ids, or define missing items and return null.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | ID}
*/
getMissing (transaction, store) {
const origin = this.origin
const rightOrigin = this.rightOrigin
const parent = /** @type {ID} */ (this.parent)
if (origin && origin.clock >= getState(store, origin.client)) {
return this.origin
}
if (rightOrigin && rightOrigin.clock >= getState(store, rightOrigin.client)) {
return this.rightOrigin
}
if (parent && parent.constructor === ID && parent.clock >= getState(store, parent.client)) {
return parent
}
// We have all missing ids, now find the items
if (origin) {
this.left = getItemCleanEnd(transaction, store, origin)
this.origin = this.left.lastId
}
if (rightOrigin) {
this.right = getItemCleanStart(transaction, rightOrigin)
this.rightOrigin = this.right.id
}
if (parent && parent.constructor === ID) {
if (parent.clock < getState(store, parent.client)) {
const parentItem = getItem(store, parent)
if (parentItem.constructor === GC) {
this.parent = null
} else {
this.parent = /** @type {ContentType} */ (parentItem.content).type
}
} else {
return parent
}
}
// only set item if this shouldn't be garbage collected
if (!this.parent) {
if (this.left && this.left.constructor === Item) {
this.parent = this.left.parent
this.parentSub = this.left.parentSub
}
if (this.right && this.right.constructor === Item) {
this.parent = this.right.parent
this.parentSub = this.right.parentSub
}
}
return null
}
/**
* @param {Transaction} transaction
* @private
* @param {number} offset
*/
integrate (transaction) {
integrate (transaction, offset) {
const store = transaction.doc.store
const id = this.id
const parent = this.parent
if (offset > 0) {
this.id.clock += offset
this.left = getItemCleanEnd(transaction, store, createID(this.id.client, this.id.clock - 1))
this.origin = this.left.lastId
this.content = this.content.splice(offset)
this.length -= offset
}
const parentSub = this.parentSub
const length = this.length
/**
* @type {Item|null}
*/
let o
// set o to the first conflicting item
if (this.left !== null) {
o = this.left.right
} else if (parentSub !== null) {
o = parent._map.get(parentSub) || null
while (o !== null && o.left !== null) {
o = o.left
}
} else {
o = parent._start
}
// TODO: use something like DeleteSet here (a tree implementation would be best)
/**
* @type {Set<Item>}
*/
const conflictingItems = new Set()
/**
* @type {Set<Item>}
*/
const itemsBeforeOrigin = new Set()
// Let c in conflictingItems, b in itemsBeforeOrigin
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
// Note that conflictingItems is a subset of itemsBeforeOrigin
while (o !== null && o !== this.right) {
itemsBeforeOrigin.add(o)
conflictingItems.add(o)
if (compareIDs(this.origin, o.origin)) {
// case 1
if (o.id.client < id.client) {
this.left = o
conflictingItems.clear()
}
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
// case 2
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
this.left = o
conflictingItems.clear()
const parent = /** @type {AbstractType<any>|null} */ (this.parent)
if (parent) {
/**
* @type {Item|null}
*/
let left = this.left
/**
* @type {Item|null}
*/
let o
// set o to the first conflicting item
if (left !== null) {
o = left.right
} else if (parentSub !== null) {
o = parent._map.get(parentSub) || null
while (o !== null && o.left !== null) {
o = o.left
}
} else {
break
o = parent._start
}
o = o.right
}
// reconnect left/right + update parent map/start if necessary
if (this.left !== null) {
const right = this.left.right
this.right = right
this.left.right = this
} else {
let r
if (parentSub !== null) {
r = parent._map.get(parentSub) || null
while (r !== null && r.left !== null) {
r = r.left
// TODO: use something like DeleteSet here (a tree implementation would be best)
// @todo use global set definitions
/**
* @type {Set<Item>}
*/
const conflictingItems = new Set()
/**
* @type {Set<Item>}
*/
const itemsBeforeOrigin = new Set()
// Let c in conflictingItems, b in itemsBeforeOrigin
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
// Note that conflictingItems is a subset of itemsBeforeOrigin
while (o !== null && o !== this.right) {
itemsBeforeOrigin.add(o)
conflictingItems.add(o)
if (compareIDs(this.origin, o.origin)) {
// case 1
if (o.id.client < this.id.client) {
left = o
conflictingItems.clear()
}
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
// case 2
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
left = o
conflictingItems.clear()
}
} else {
break
}
o = o.right
}
this.left = left
// reconnect left/right + update parent map/start if necessary
if (left !== null) {
const right = left.right
this.right = right
left.right = this
} else {
r = parent._start
parent._start = this
let r
if (parentSub !== null) {
r = parent._map.get(parentSub) || null
while (r !== null && r.left !== null) {
r = r.left
}
} else {
r = parent._start
parent._start = this
}
this.right = r
}
this.right = r
}
if (this.right !== null) {
this.right.left = this
} else if (parentSub !== null) {
// set as current parent value if right === null and this is parentSub
parent._map.set(parentSub, this)
if (this.left !== null) {
// this is the current attribute value of parent. delete right
this.left.delete(transaction)
if (this.right !== null) {
this.right.left = this
} else if (parentSub !== null) {
// set as current parent value if right === null and this is parentSub
parent._map.set(parentSub, this)
if (left !== null) {
// this is the current attribute value of parent. delete right
left.delete(transaction)
}
}
}
// adjust length of parent
if (parentSub === null && this.countable && !this.deleted) {
parent._length += length
}
addStruct(store, this)
this.content.integrate(transaction, this)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(parentSub)
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction)
// adjust length of parent
if (parentSub === null && this.countable && !this.deleted) {
parent._length += length
}
addStruct(store, this)
this.content.integrate(transaction, this)
// add parent to transaction.changed
addChangedTypeToTransaction(transaction, parent, parentSub)
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction)
}
} else {
// parent is not defined. Integrate GC struct instead
new GC(this.id, this.length).integrate(transaction, 0)
}
}
/**
* Returns the next non-deleted item
* @private
*/
get next () {
let n = this.right
@@ -255,7 +493,6 @@ export class Item extends AbstractStruct {
/**
* Returns the previous non-deleted item
* @private
*/
get prev () {
let n = this.left
@@ -265,72 +502,14 @@ export class Item extends AbstractStruct {
return n
}
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Set<Item>} 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 = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, this.parentSub, this.content.copy())
this.redone.integrate(transaction)
return true
}
/**
* Computes the last content address of this Item.
*/
get lastId () {
return createID(this.id.client, this.id.clock + this.length - 1)
// allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible
return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1)
}
/**
* Try to merge two items
*
@@ -345,9 +524,14 @@ export class Item extends AbstractStruct {
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
if (this.right !== null) {
this.right.left = this
@@ -365,7 +549,7 @@ export class Item extends AbstractStruct {
*/
delete (transaction) {
if (!this.deleted) {
const parent = this.parent
const parent = /** @type {AbstractType<any>} */ (this.parent)
// adjust the length of parent
if (this.countable && this.parentSub === null) {
parent._length -= this.length
@@ -380,8 +564,6 @@ export class Item extends AbstractStruct {
/**
* @param {StructStore} store
* @param {boolean} parentGCd
*
* @private
*/
gc (store, parentGCd) {
if (!this.deleted) {
@@ -403,8 +585,6 @@ export class Item extends AbstractStruct {
*
* @param {encoding.Encoder} encoder The encoder to write data to.
* @param {number} offset
*
* @private
*/
write (encoder, offset) {
const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin
@@ -422,8 +602,9 @@ export class Item extends AbstractStruct {
writeID(encoder, rightOrigin)
}
if (origin === null && rightOrigin === null) {
const parent = this.parent
if (parent._item === null) {
const parent = /** @type {AbstractType<any>} */ (this.parent)
const parentItem = parent._item
if (parentItem === null) {
// parent type on y._map
// find the correct key
const ykey = findRootTypeKey(parent)
@@ -431,7 +612,7 @@ export class Item extends AbstractStruct {
encoding.writeVarString(encoder, ykey)
} else {
encoding.writeVarUint(encoder, 0) // write parent id
writeID(encoder, parent._item.id)
writeID(encoder, parentItem.id)
}
if (parentSub !== null) {
encoding.writeVarString(encoder, parentSub)
@@ -460,7 +641,8 @@ export const contentRefs = [
readContentString,
readContentEmbed,
readContentFormat,
readContentType
readContentType,
readContentAny
]
/**
@@ -473,12 +655,14 @@ export class AbstractContent {
getLength () {
throw error.methodUnimplemented()
}
/**
* @return {Array<any>}
*/
getContent () {
throw error.methodUnimplemented()
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
@@ -491,12 +675,14 @@ export class AbstractContent {
isCountable () {
throw error.methodUnimplemented()
}
/**
* @return {AbstractContent}
*/
copy () {
throw error.methodUnimplemented()
}
/**
* @param {number} offset
* @return {AbstractContent}
@@ -504,6 +690,7 @@ export class AbstractContent {
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {AbstractContent} right
* @return {boolean}
@@ -511,6 +698,7 @@ export class AbstractContent {
mergeWith (right) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
* @param {Item} item
@@ -518,18 +706,21 @@ export class AbstractContent {
integrate (transaction, item) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
throw error.methodUnimplemented()
}
/**
* @param {StructStore} store
*/
gc (store) {
throw error.methodUnimplemented()
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
@@ -537,6 +728,7 @@ export class AbstractContent {
write (encoder, offset) {
throw error.methodUnimplemented()
}
/**
* @return {number}
*/
@@ -546,121 +738,49 @@ export class AbstractContent {
}
/**
* @private
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
* @param {Doc} doc
*/
export class ItemRef extends AbstractStructRef {
export const readItem = (decoder, id, info, doc) => {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
* The item that was originally to the left of this item.
* @type {ID | null}
*/
constructor (decoder, id, info) {
super(id)
/**
* The item that was originally to the left of this item.
* @type {ID | null}
*/
this.left = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
/**
* The item that was originally to the right of this item.
* @type {ID | null}
*/
this.right = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
/**
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
* and we read the next string as parentYKey.
* It indicates how we store/retrieve parent from `y.share`
* @type {string|null}
*/
this.parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
/**
* The parent type.
* @type {ID | null}
*/
this.parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : null
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
*/
this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
const missing = this._missing
if (this.left !== null) {
missing.push(this.left)
}
if (this.right !== null) {
missing.push(this.right)
}
if (this.parent !== null) {
missing.push(this.parent)
}
/**
* @type {AbstractContent}
*/
this.content = readItemContent(decoder, info)
this.length = this.content.getLength()
}
const origin = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {Item|GC}
* The item that was originally to the right of this item.
* @type {ID | null}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
/**
* @type {ID}
*/
const id = this.id
this.id = createID(id.client, id.clock + offset)
this.left = createID(this.id.client, this.id.clock - 1)
this.content = this.content.splice(offset)
}
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
/**
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
* and we read the next string as parentYKey.
* It indicates how we store/retrieve parent from `y.share`
* @type {string|null}
*/
const parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
/**
* The parent type.
* @type {ID | AbstractType<any> | null}
*/
const parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : (parentYKey ? doc.get(parentYKey) : null)
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
*/
const parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
const right = this.right === null ? null : getItemCleanStart(transaction, store, this.right)
let parent = null
let parentSub = this.parentSub
if (this.parent !== null) {
const parentItem = getItem(store, this.parent)
// Edge case: toStruct is called with an offset > 0. In this case left is defined.
// Depending in which order structs arrive, left may be GC'd and the parent not
// deleted. This is why we check if left is GC'd. Strictly we don't have
// to check if right is GC'd, but we will in case we run into future issues
if (!parentItem.deleted && (left === null || left.constructor !== GC) && (right === null || right.constructor !== GC)) {
parent = /** @type {ContentType} */ (parentItem.content).type
}
} else if (this.parentYKey !== null) {
parent = transaction.doc.get(this.parentYKey)
} else if (left !== null) {
if (left.constructor !== GC) {
parent = left.parent
parentSub = left.parentSub
}
} else if (right !== null) {
if (right.constructor !== GC) {
parent = right.parent
parentSub = right.parentSub
}
} else {
throw error.unexpectedCase()
}
/**
* @type {AbstractContent}
*/
const content = readItemContent(decoder, info)
return parent === null
? new GC(this.id, this.length)
: new Item(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.content
)
}
return new Item(id, null, origin, null, rightOrigin, parent, parentSub, content)
}

View File

@@ -4,14 +4,14 @@ import {
callEventHandlerListeners,
addEventHandlerListener,
createEventHandler,
nextID,
getState,
isVisible,
ContentType,
ContentJSON,
ContentBinary,
createID,
ContentAny,
ContentBinary,
getItemCleanStart,
Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
ID, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
@@ -19,10 +19,25 @@ import * as iterator from 'lib0/iterator.js'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
/**
* Accumulate all (list) children of a type and return them as an Array.
*
* @param {AbstractType<any>} t
* @return {Array<Item>}
*/
export const getTypeChildren = t => {
let s = t._start
const arr = []
while (s) {
arr.push(s)
s = s.right
}
return arr
}
/**
* Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers).
* @private
*
* @template EventType
* @param {AbstractType<EventType>} type
@@ -30,7 +45,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
* @param {EventType} event
*/
export const callTypeObservers = (type, transaction, event) => {
callEventHandlerListeners(type._eH, event, transaction)
const changedType = type
const changedParentTypes = transaction.changedParentTypes
while (true) {
// @ts-ignore
@@ -38,8 +53,9 @@ export const callTypeObservers = (type, transaction, event) => {
if (type._item === null) {
break
}
type = type._item.parent
type = /** @type {AbstractType<any>} */ (type._item.parent)
}
callEventHandlerListeners(changedType._eH, event, transaction)
}
/**
@@ -53,17 +69,14 @@ export class AbstractType {
*/
this._item = null
/**
* @private
* @type {Map<string,Item>}
*/
this._map = new Map()
/**
* @private
* @type {Item|null}
*/
this._start = null
/**
* @private
* @type {Doc|null}
*/
this.doc = null
@@ -89,7 +102,6 @@ export class AbstractType {
*
* @param {Doc} y The Yjs instance
* @param {Item|null} item
* @private
*/
_integrate (y, item) {
this.doc = y
@@ -98,7 +110,6 @@ export class AbstractType {
/**
* @return {AbstractType<EventType>}
* @private
*/
_copy () {
throw error.methodUnimplemented()
@@ -106,7 +117,6 @@ export class AbstractType {
/**
* @param {encoding.Encoder} encoder
* @private
*/
_write (encoder) { }
@@ -127,8 +137,6 @@ export class AbstractType {
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*
* @private
*/
_callObserver (transaction, parentSubs) { /* skip if no type is specified */ }
@@ -170,7 +178,7 @@ export class AbstractType {
/**
* @abstract
* @return {Object | Array | number | string}
* @return {any}
*/
toJSON () {}
}
@@ -367,15 +375,18 @@ export const typeListGet = (type, index) => {
*/
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
let left = referenceItem
const doc = transaction.doc
const ownClientId = doc.clientID
const store = doc.store
const right = referenceItem === null ? parent._start : referenceItem.right
/**
* @type {Array<Object|Array|number>}
* @type {Array<Object|Array<any>|number>}
*/
let jsonContent = []
const packJsonContent = () => {
if (jsonContent.length > 0) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentJSON(jsonContent))
left.integrate(transaction)
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction, 0)
jsonContent = []
}
}
@@ -393,13 +404,13 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
switch (c.constructor) {
case Uint8Array:
case ArrayBuffer:
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(transaction)
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(transaction, 0)
break
default:
if (c instanceof AbstractType) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentType(c))
left.integrate(transaction)
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
left.integrate(transaction, 0)
} else {
throw new Error('Unexpected content type in insert operation')
}
@@ -428,7 +439,7 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index <= n.length) {
if (index < n.length) {
// insert in-between
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
break
}
@@ -454,7 +465,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
index -= n.length
}
@@ -463,7 +474,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
while (length > 0 && n !== null) {
if (!n.deleted) {
if (length < n.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + length))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
}
n.delete(transaction)
length -= n.length
@@ -501,9 +512,11 @@ export const typeMapDelete = (transaction, parent, key) => {
*/
export const typeMapSet = (transaction, parent, key, value) => {
const left = parent._map.get(key) || null
const doc = transaction.doc
const ownClientId = doc.clientID
let content
if (value == null) {
content = new ContentJSON([value])
content = new ContentAny([value])
} else {
switch (value.constructor) {
case Number:
@@ -511,10 +524,10 @@ export const typeMapSet = (transaction, parent, key, value) => {
case Boolean:
case Array:
case String:
content = new ContentJSON([value])
content = new ContentAny([value])
break
case Uint8Array:
content = new ContentBinary(value)
content = new ContentBinary(/** @type {Uint8Array} */ (value))
break
default:
if (value instanceof AbstractType) {
@@ -524,7 +537,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
}
}
}
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction)
new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
}
/**
@@ -551,7 +564,7 @@ export const typeMapGetAll = (parent) => {
/**
* @type {Object<string,any>}
*/
let res = {}
const res = {}
for (const [key, value] of parent._map) {
if (!value.deleted) {
res[key] = value.content.getContent()[value.length - 1]
@@ -584,7 +597,7 @@ export const typeMapHas = (parent, key) => {
*/
export const typeMapGetSnapshot = (parent, key, snapshot) => {
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
}
return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined

View File

@@ -40,7 +40,7 @@ export class YArrayEvent extends YEvent {
* A shared Array implementation.
* @template T
* @extends AbstractType<YArrayEvent<T>>
* @implements {IterableIterator<T>}
* @implements {Iterable<T>}
*/
export class YArray extends AbstractType {
constructor () {
@@ -51,6 +51,7 @@ export class YArray extends AbstractType {
*/
this._prelimContent = []
}
/**
* Integrate this type into the Yjs instance.
*
@@ -60,24 +61,26 @@ export class YArray extends AbstractType {
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array} */ (this._prelimContent))
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this._prelimContent = null
}
_copy () {
return new YArray()
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
/**
* Creates YArrayEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*
* @private
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
@@ -105,7 +108,7 @@ export class YArray extends AbstractType {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
/** @type {Array} */ (this._prelimContent).splice(index, 0, ...content)
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
@@ -118,6 +121,15 @@ export class YArray extends AbstractType {
this.insert(this.length, content)
}
/**
* Preppends content to this YArray.
*
* @param {Array<T>} content Array of content to preppend.
*/
unshift (content) {
this.insert(0, content)
}
/**
* Deletes elements starting from an index.
*
@@ -130,7 +142,7 @@ export class YArray extends AbstractType {
typeListDelete(transaction, this, index, length)
})
} else {
/** @type {Array} */ (this._prelimContent).splice(index, length)
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
}
}
@@ -193,7 +205,6 @@ export class YArray extends AbstractType {
/**
* @param {encoding.Encoder} encoder
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YArrayRefID)

View File

@@ -42,17 +42,28 @@ export class YMapEvent extends YEvent {
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<T>>
* @implements {IterableIterator}
* @implements {Iterable<T>}
*/
export class YMap extends AbstractType {
constructor () {
/**
*
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
*/
constructor (entries) {
super()
/**
* @type {Map<string,any>?}
* @private
*/
this._prelimContent = new Map()
this._prelimContent = null
if (entries === undefined) {
this._prelimContent = new Map()
} else {
this._prelimContent = new Map(entries)
}
}
/**
* Integrate this type into the Yjs instance.
*
@@ -62,23 +73,24 @@ export class YMap extends AbstractType {
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
for (let [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
for (const [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
this.set(key, value)
}
this._prelimContent = null
}
_copy () {
return new YMap()
}
/**
* Creates YMapEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*
* @private
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs))
@@ -94,7 +106,7 @@ export class YMap extends AbstractType {
* @type {Object<string,T>}
*/
const map = {}
for (let [key, item] of this._map) {
for (const [key, item] of this._map) {
if (!item.deleted) {
const v = item.content.getContent()[item.length - 1]
map[key] = v instanceof AbstractType ? v.toJSON() : v
@@ -103,10 +115,19 @@ export class YMap extends AbstractType {
return map
}
/**
* Returns the size of the YMap (count of key/value pairs)
*
* @return {number}
*/
get size () {
return [...createMapIterator(this._map)].length
}
/**
* Returns the keys for each element in the YMap Type.
*
* @return {Iterator<string>}
* @return {IterableIterator<string>}
*/
keys () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0])
@@ -115,7 +136,7 @@ export class YMap extends AbstractType {
/**
* Returns the keys for each element in the YMap Type.
*
* @return {Iterator<string>}
* @return {IterableIterator<string>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
@@ -130,6 +151,24 @@ export class YMap extends AbstractType {
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 every 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
}
/**
* @return {IterableIterator<T>}
*/
@@ -191,8 +230,6 @@ export class YMap extends AbstractType {
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YMapRefID)

View File

@@ -6,21 +6,34 @@
import {
YEvent,
AbstractType,
nextID,
createID,
getItemCleanStart,
getState,
isVisible,
createID,
YTextRefID,
callTypeObservers,
transact,
ContentEmbed,
GC,
ContentFormat,
ContentString,
Doc, Item, Snapshot, StructStore, Transaction // eslint-disable-line
splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js'
import * as object from 'lib0/object.js'
import * as map from 'lib0/map.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 {
/**
@@ -59,7 +72,6 @@ export class ItemInsertionResult extends ItemListPosition {
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {Map<string,any>} currentAttributes
* @param {Item|null} left
* @param {Item|null} right
@@ -69,7 +81,7 @@ export class ItemInsertionResult extends ItemListPosition {
* @private
* @function
*/
const findNextPosition = (transaction, store, currentAttributes, left, right, count) => {
const findNextPosition = (transaction, currentAttributes, left, right, count) => {
while (right !== null && count > 0) {
switch (right.content.constructor) {
case ContentEmbed:
@@ -77,7 +89,7 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
if (!right.deleted) {
if (count < right.length) {
// 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
}
@@ -96,7 +108,6 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {AbstractType<any>} parent
* @param {number} index
* @return {ItemTextListPosition}
@@ -104,11 +115,10 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
* @private
* @function
*/
const findPosition = (transaction, store, parent, index) => {
let currentAttributes = new Map()
let left = null
let right = parent._start
return findNextPosition(transaction, store, currentAttributes, left, right, index)
const findPosition = (transaction, parent, index) => {
const currentAttributes = new Map()
const right = parent._start
return findNextPosition(transaction, currentAttributes, null, right, index)
}
/**
@@ -116,21 +126,20 @@ const findPosition = (transaction, store, parent, index) => {
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {ItemListPosition} currPos
* @param {Map<string,any>} negatedAttributes
* @return {ItemListPosition}
*
* @private
* @function
*/
const insertNegatedAttributes = (transaction, parent, left, right, negatedAttributes) => {
const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => {
let { left, right } = currPos
// check if we really need to remove attributes
while (
right !== null && (
right.deleted === true || (
right.content.constructor === ContentFormat &&
(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key) === /** @type {ContentFormat} */ (right.content).value)
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
)
)
) {
@@ -140,11 +149,14 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
left = right
right = right.right
}
for (let [key, val] of negatedAttributes) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction)
const doc = transaction.doc
const ownClientId = doc.clientID
for (const [key, val] of negatedAttributes) {
left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction, 0)
}
return { left, right }
currPos.left = left
currPos.right = right
}
/**
@@ -164,23 +176,22 @@ const updateCurrentAttributes = (currentAttributes, format) => {
}
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
* @return {ItemListPosition}
*
* @private
* @function
*/
const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => {
const minimizeAttributeChanges = (currPos, currentAttributes, attributes) => {
// go right while attributes[right.key] === right.value (or right is deleted)
let { left, right } = currPos
while (true) {
if (right === null) {
break
} else if (right.deleted) {
// continue
} else if (right.content.constructor === ContentFormat && (attributes[(/** @type {ContentFormat} */ (right.content)).key] || null) === /** @type {ContentFormat} */ (right.content).value) {
} else if (right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (right.content)).key] || null, /** @type {ContentFormat} */ (right.content).value)) {
// found a format, update currentAttributes and continue
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
} else {
@@ -189,96 +200,96 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
left = right
right = right.right
}
return new ItemListPosition(left, right)
currPos.left = left
currPos.right = right
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
* @return {ItemInsertionResult}
* @return {Map<string,any>}
*
* @private
* @function
**/
const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => {
const insertAttributes = (transaction, parent, currPos, currentAttributes, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
const negatedAttributes = new Map()
// insert format-start items
for (let key in attributes) {
for (const key in attributes) {
const val = attributes[key]
const currentVal = currentAttributes.get(key) || null
if (currentVal !== val) {
if (!equalAttrs(currentVal, val)) {
// save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal)
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction)
const { left, right } = currPos
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
currPos.left.integrate(transaction, 0)
}
}
return new ItemInsertionResult(left, right, negatedAttributes)
return negatedAttributes
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {string} text
* @param {string|object} text
* @param {Object<string,any>} attributes
* @return {ItemListPosition}
*
* @private
* @function
**/
const insertText = (transaction, parent, left, right, currentAttributes, text, attributes) => {
for (let [key] of currentAttributes) {
const insertText = (transaction, parent, currPos, currentAttributes, text, attributes) => {
for (const [key] of currentAttributes) {
if (attributes[key] === undefined) {
attributes[key] = null
}
}
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
left = insertPos.left
right = insertPos.right
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(currPos, currentAttributes, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
// insert content
const content = text.constructor === String ? new ContentString(text) : new ContentEmbed(text)
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, content)
left.integrate(transaction)
return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes)
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : new ContentEmbed(text)
const { left, right } = currPos
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
currPos.left.integrate(transaction, 0)
return insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {number} length
* @param {Object<string,any>} attributes
* @return {ItemListPosition}
*
* @private
* @function
*/
const formatText = (transaction, parent, left, right, currentAttributes, length, attributes) => {
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
const negatedAttributes = insertPos.negatedAttributes
left = insertPos.left
right = insertPos.right
const formatText = (transaction, parent, currPos, currentAttributes, length, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(currPos, currentAttributes, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
let { left, right } = currPos
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
while (length > 0 && right !== null) {
if (right.deleted === false) {
if (!right.deleted) {
switch (right.content.constructor) {
case ContentFormat:
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (attr === value) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
negatedAttributes.set(key, value)
@@ -287,10 +298,11 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
}
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
break
}
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
break
@@ -299,13 +311,129 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
left = right
right = right.right
}
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
// 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(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentString(newlines))
left.integrate(transaction, 0)
}
currPos.left = left
currPos.right = right
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
/**
* Call this function after string content has been deleted in order to
* clean up formatting Items.
*
* @param {Transaction} transaction
* @param {Item} start
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startAttributes
* @param {Map<string,any>} endAttributes This attribute is modified!
* @return {number} The amount of formatting Items deleted.
*
* @function
*/
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
}
end = end.right
}
let cleanups = 0
while (start !== end) {
if (!start.deleted) {
const content = start.content
switch (content.constructor) {
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (content)
if ((endAttributes.get(key) || null) !== value || (startAttributes.get(key) || null) === value) {
// Either this format is overwritten or it is not necessary because the attribute already existed.
start.delete(transaction)
cleanups++
}
break
}
}
}
start = /** @type {Item} */ (start.right)
}
return cleanups
}
/**
* @param {Transaction} transaction
* @param {Item|null} left
* @param {Item|null} right
* @param {Item | null} item
*/
const cleanupContextlessFormattingGap = (transaction, item) => {
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || (item.right.content.constructor !== ContentString && item.right.content.constructor !== ContentEmbed))) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || (item.content.constructor !== ContentString && item.content.constructor !== ContentEmbed))) {
if (!item.deleted && item.content.constructor === ContentFormat) {
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
item.delete(transaction)
} else {
attrs.add(key)
}
}
item = item.left
}
}
/**
* This function is experimental and subject to change / be removed.
*
* Ideally, we don't need this function at all. Formatting attributes should be cleaned up
* automatically after each change. This function iterates twice over the complete YText type
* and removes unnecessary formatting attributes. This is also helpful for testing.
*
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
*
* @param {YText} type
* @return {number} How many formatting attributes have been cleaned up.
*/
export const cleanupYTextFormatting = type => {
let res = 0
transact(/** @type {Doc} */ (type.doc), transaction => {
let start = /** @type {Item} */ (type._start)
let end = type._start
let startAttributes = map.create()
const currentAttributes = map.copy(startAttributes)
while (end) {
if (end.deleted === false) {
switch (end.content.constructor) {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
break
case ContentEmbed:
case ContentString:
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
startAttributes = map.copy(currentAttributes)
start = end
break
}
}
end = end.right
}
})
return res
}
/**
* @param {Transaction} transaction
* @param {ItemListPosition} currPos
* @param {Map<string,any>} currentAttributes
* @param {number} length
* @return {ItemListPosition}
@@ -313,7 +441,10 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
* @private
* @function
*/
const deleteText = (transaction, left, right, currentAttributes, length) => {
const deleteText = (transaction, currPos, currentAttributes, length) => {
const startAttrs = map.copy(currentAttributes)
const start = currPos.right
let { left, right } = currPos
while (length > 0 && right !== null) {
if (right.deleted === false) {
switch (right.content.constructor) {
@@ -323,7 +454,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
right.delete(transaction)
@@ -333,7 +464,12 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
left = right
right = right.right
}
return { left, right }
if (start) {
cleanupFormattingGap(transaction, start, right, startAttrs, map.copy(currentAttributes))
}
currPos.left = left
currPos.right = right
return currPos
}
/**
@@ -382,11 +518,11 @@ export class YTextEvent extends YEvent {
constructor (ytext, transaction) {
super(ytext, transaction)
/**
* @private
* @type {Array<DeltaItem>|null}
*/
this._delta = null
}
/**
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
@@ -411,7 +547,10 @@ export class YTextEvent extends YEvent {
/**
* @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 retain = 0
let deleteLen = 0
@@ -430,7 +569,7 @@ export class YTextEvent extends YEvent {
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
for (let [key, value] of currentAttributes) {
for (const [key, value] of currentAttributes) {
if (value !== null) {
op.attributes[key] = value
}
@@ -442,7 +581,7 @@ export class YTextEvent extends YEvent {
op = { retain }
if (Object.keys(attributes).length > 0) {
op.attributes = {}
for (let key in attributes) {
for (const key in attributes) {
op.attributes[key] = attributes[key]
}
}
@@ -500,16 +639,16 @@ export class YTextEvent extends YEvent {
retain += item.length
}
break
case ContentFormat:
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (item.content)
if (this.adds(item)) {
if (!this.deletes(item)) {
const curVal = currentAttributes.get(key) || null
if (curVal !== value) {
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
if (value === (oldAttributes.get(key) || null)) {
if (equalAttrs(value, (oldAttributes.get(key) || null))) {
delete attributes[key]
} else {
attributes[key] = value
@@ -521,7 +660,7 @@ export class YTextEvent extends YEvent {
} else if (this.deletes(item)) {
oldAttributes.set(key, value)
const curVal = currentAttributes.get(key) || null
if (curVal !== value) {
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
@@ -531,7 +670,7 @@ export class YTextEvent extends YEvent {
oldAttributes.set(key, value)
const attr = attributes[key]
if (attr !== undefined) {
if (attr !== value) {
if (!equalAttrs(attr, value)) {
if (action === 'retain') {
addOp()
}
@@ -552,12 +691,13 @@ export class YTextEvent extends YEvent {
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (item.content))
}
break
}
}
item = item.right
}
addOp()
while (delta.length > 0) {
let lastOp = delta[delta.length - 1]
const lastOp = delta[delta.length - 1]
if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
// retain delta's if they don't assign attributes
delta.pop()
@@ -589,11 +729,15 @@ export class YText extends AbstractType {
/**
* Array of pending operations on this type
* @type {Array<function():void>?}
* @private
*/
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
}
/**
* Number of characters of this text type.
*
* @type {number}
*/
get length () {
return this._length
}
@@ -601,8 +745,6 @@ export class YText extends AbstractType {
/**
* @param {Doc} y
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
@@ -614,16 +756,59 @@ export class YText extends AbstractType {
this._pending = null
}
_copy () {
return new YText()
}
/**
* Creates YTextEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*
* @private
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YTextEvent(this, transaction))
const event = new YTextEvent(this, transaction)
const doc = transaction.doc
// If a remote change happened, we try to cleanup potential formatting duplicates.
if (!transaction.local) {
// check if another formatting item was inserted
let foundFormattingItem = false
for (const [client, afterClock] of transaction.afterState) {
const clock = transaction.beforeState.get(client) || 0
if (afterClock === clock) {
continue
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
// @ts-ignore
if (!item.deleted && item.content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
if (foundFormattingItem) {
break
}
}
transact(doc, t => {
if (foundFormattingItem) {
// If a formatting item was inserted, we simply clean the whole type.
// We need to compute currentAttributes for the current position anyway.
cleanupYTextFormatting(this)
} else {
// If no formatting attribute was inserted, we can make due with contextless
// formatting cleanups.
// Contextless: it is not necessary to compute currentAttributes for the affected position.
iterateDeletedStructs(t, transaction.deleteSet, item => {
if (item instanceof GC) {
return
}
if (item.parent === this) {
cleanupContextlessFormattingGap(t, item)
}
})
}
})
}
callTypeObservers(this, transaction, event)
}
/**
@@ -646,29 +831,50 @@ export class YText extends AbstractType {
return str
}
/**
* Returns the unformatted string representation of this YText type.
*
* @return {string}
* @public
*/
toJSON () {
return this.toString()
}
/**
* Apply a {@link Delta} on this shared YText type.
*
* @param {any} delta The changes to apply on this element.
* @param {object} [opts]
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
*
*
* @public
*/
applyDelta (delta) {
applyDelta (delta, { sanitize = true } = {}) {
if (this.doc !== null) {
transact(this.doc, transaction => {
/**
* @type {ItemListPosition}
*/
let pos = new ItemListPosition(null, this._start)
const currPos = new ItemListPosition(null, this._start)
const currentAttributes = new Map()
for (let i = 0; i < delta.length; i++) {
const op = delta[i]
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 = (!sanitize && typeof op.insert === 'string' && i === delta.length - 1 && currPos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
if (typeof ins !== 'string' || ins.length > 0) {
insertText(transaction, this, currPos, currentAttributes, ins, op.attributes || {})
}
} else if (op.retain !== undefined) {
pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {})
formatText(transaction, this, currPos, currentAttributes, op.retain, op.attributes || {})
} else if (op.delete !== undefined) {
pos = deleteText(transaction, pos.left, pos.right, currentAttributes, op.delete)
deleteText(transaction, currPos, currentAttributes, op.delete)
}
}
})
@@ -682,16 +888,18 @@ export class YText extends AbstractType {
*
* @param {Snapshot} [snapshot]
* @param {Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', ID):any} [computeYChange]
* @return {any} The Delta representation of this type.
*
* @public
*/
toDelta (snapshot, prevSnapshot) {
toDelta (snapshot, prevSnapshot, computeYChange) {
/**
* @type{Array<any>}
*/
const ops = []
const currentAttributes = new Map()
const doc = /** @type {Doc} */ (this.doc)
let str = ''
let n = this._start
function packStr () {
@@ -702,7 +910,7 @@ export class YText extends AbstractType {
*/
const attributes = {}
let addAttributes = false
for (let [key, value] of currentAttributes) {
for (const [key, value] of currentAttributes) {
addAttributes = true
attributes[key] = value
}
@@ -717,42 +925,67 @@ export class YText extends AbstractType {
str = ''
}
}
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', { 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')
}
str += /** @type {ContentString} */ (n.content).str
break
case ContentEmbed:
packStr()
ops.push({
insert: /** @type {ContentEmbed} */ (n.content).embed
})
break
case ContentFormat:
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
break
}
// snapshots are merged again after the transaction, so we need to keep the
// transalive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
n = n.right
}
packStr()
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
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()
/**
* @type {Object<string,any>}
*/
const op = {
insert: /** @type {ContentEmbed} */ (n.content).embed
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
for (const [key, value] of currentAttributes) {
attrs[key] = value
}
}
ops.push(op)
break
}
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
}
n = n.right
}
packStr()
}, splitSnapshotAffectedStructs)
return ops
}
@@ -761,20 +994,25 @@ export class YText extends AbstractType {
*
* @param {number} index The index at which to start inserting.
* @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
* Text.
* @public
*/
insert (index, text, attributes = {}) {
insert (index, text, attributes) {
if (text.length <= 0) {
return
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
insertText(transaction, this, left, right, currentAttributes, text, attributes)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (!attributes) {
attributes = {}
// @ts-ignore
currentAttributes.forEach((v, k) => { attributes[k] = v })
}
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, text, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insert(index, text, attributes))
@@ -798,8 +1036,8 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
insertText(transaction, this, left, right, currentAttributes, embed, attributes)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, embed, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insertEmbed(index, embed, attributes))
@@ -821,8 +1059,8 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
deleteText(transaction, left, right, currentAttributes, length)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
deleteText(transaction, new ItemListPosition(left, right), currentAttributes, length)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.delete(index, length))
@@ -840,14 +1078,17 @@ export class YText extends AbstractType {
* @public
*/
format (index, length, attributes) {
if (length === 0) {
return
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (right === null) {
return
}
formatText(transaction, this, left, right, currentAttributes, length, attributes)
formatText(transaction, this, new ItemListPosition(left, right), currentAttributes, length, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))
@@ -856,8 +1097,6 @@ export class YText extends AbstractType {
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YTextRefID)

View File

@@ -24,10 +24,9 @@ import * as decoding from 'lib0/decoding.js'
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName.toUpperCase()
this.nodeName = nodeName
/**
* @type {Map<string, any>|null}
* @private
*/
this._prelimAttrs = new Map()
}
@@ -41,23 +40,19 @@ export class YXmlElement extends YXmlFragment {
*
* @param {Doc} y The Yjs instance
* @param {Item} item
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array} */ (this._prelimContent))
this._prelimContent = null
;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
this.setAttribute(key, value)
})
this._prelimContent = null
this._prelimAttrs = null
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @return {YXmlElement}
* @private
*/
_copy () {
return new YXmlElement(this.nodeName)
@@ -76,7 +71,7 @@ export class YXmlElement extends YXmlFragment {
const attrs = this.getAttributes()
const stringBuilder = []
const keys = []
for (let key in attrs) {
for (const key in attrs) {
keys.push(key)
}
keys.sort()
@@ -142,7 +137,7 @@ export class YXmlElement extends YXmlFragment {
* Returns all attribute name/value pairs in a JSON Object.
*
* @param {Snapshot} [snapshot]
* @return {Object} A JSON Object that describes the attributes.
* @return {Object<string, any>} A JSON Object that describes the attributes.
*
* @public
*/
@@ -167,8 +162,8 @@ export class YXmlElement extends YXmlFragment {
*/
toDOM (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
let attrs = this.getAttributes()
for (let key in attrs) {
const attrs = this.getAttributes()
for (const key in attrs) {
dom.setAttribute(key, attrs[key])
}
typeListForEach(this, yxml => {
@@ -186,7 +181,6 @@ export class YXmlElement extends YXmlFragment {
*
* This is called when this Item is sent to a remote peer.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
@@ -199,7 +193,6 @@ export class YXmlElement extends YXmlFragment {
* @param {decoding.Decoder} decoder
* @return {YXmlElement}
*
* @private
* @function
*/
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))

View File

@@ -14,7 +14,7 @@ import {
YXmlFragmentRefID,
callTypeObservers,
transact,
ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
@@ -48,7 +48,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {IterableIterator}
* @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
*/
export class YXmlTreeWalker {
/**
@@ -68,6 +68,7 @@ export class YXmlTreeWalker {
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
@@ -80,10 +81,10 @@ export class YXmlTreeWalker {
* @type {Item|null}
*/
let n = this._currentNode
let type = /** @type {ContentType} */ (n.content).type
let type = /** @type {any} */ (n.content).type
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
do {
type = /** @type {ContentType} */ (n.content).type
type = /** @type {any} */ (n.content).type
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
// walk down in the tree
n = type._start
@@ -96,7 +97,7 @@ export class YXmlTreeWalker {
} else if (n.parent === this._root) {
n = null
} else {
n = n.parent._item
n = /** @type {AbstractType<any>} */ (n.parent)._item
}
}
}
@@ -126,10 +127,34 @@ export class YXmlFragment extends AbstractType {
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
*/
_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.
*
@@ -169,7 +194,7 @@ export class YXmlFragment extends AbstractType {
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
const iterator = new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query)
const next = iterator.next()
if (next.done) {
return null
@@ -192,12 +217,11 @@ export class YXmlFragment extends AbstractType {
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
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.
@@ -215,6 +239,9 @@ export class YXmlFragment extends AbstractType {
return typeListMap(this, xml => xml.toString()).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
@@ -282,6 +309,7 @@ export class YXmlFragment extends AbstractType {
this._prelimContent.splice(index, length)
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
@@ -290,13 +318,13 @@ export class YXmlFragment extends AbstractType {
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) {

View File

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

View File

@@ -9,6 +9,10 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
_copy () {
return new YXmlText()
}
/**
* Creates a Dom Element that mirrors this YXmlText.
*
@@ -36,9 +40,9 @@ export class YXmlText extends YText {
// @ts-ignore
return this.toDelta().map(delta => {
const nestedNodes = []
for (let nodeName in delta.attributes) {
for (const nodeName in delta.attributes) {
const attrs = []
for (let key in delta.attributes[nodeName]) {
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
@@ -53,7 +57,7 @@ export class YXmlText extends YText {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[i]
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
@@ -66,14 +70,15 @@ export class YXmlText extends YText {
}).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YXmlTextRefID)

View File

@@ -1,12 +1,14 @@
import {
findIndexSS,
createID,
getState,
splitItem,
Item, AbstractStruct, StructStore, Transaction, ID // eslint-disable-line
createID,
iterateStructs,
Item, AbstractStruct, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array.js'
import * as math from 'lib0/math.js'
import * as map from 'lib0/map.js'
import * as encoding from 'lib0/encoding.js'
@@ -40,36 +42,26 @@ export class DeleteSet {
constructor () {
/**
* @type {Map<number,Array<DeleteItem>>}
* @private
*/
this.clients = new Map()
}
}
/**
* Iterate over all structs that were deleted.
*
* This function expects that the deletes structs are not merged. Hence, you can
* probably only use it in type observes and `afterTransaction` events. But not
* in `afterTransactionCleanup`.
* Iterate over all structs that the DeleteSet gc's.
*
* @param {Transaction} transaction
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(AbstractStruct):void} f
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateDeletedStructs = (ds, store, f) =>
export const iterateDeletedStructs = (transaction, ds, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(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]
let index = findIndexSS(structs, del.clock)
let struct
do {
struct = structs[index++]
f(struct)
} while (index < structs.length && structs[index].id.clock < del.clock + del.len)
iterateStructs(transaction, structs, del.clock, del.len, f)
}
})
@@ -143,6 +135,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 {ID} id
@@ -155,6 +173,8 @@ export const addToDeleteSet = (ds, id, length) => {
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
}
export const createDeleteSet = () => new DeleteSet()
/**
* @param {StructStore} ss
* @return {DeleteSet} Merged and sorted DeleteSet
@@ -163,7 +183,7 @@ export const addToDeleteSet = (ds, id, length) => {
* @function
*/
export const createDeleteSetFromStructStore = ss => {
const ds = new DeleteSet()
const ds = createDeleteSet()
ss.clients.forEach((structs, client) => {
/**
* @type {Array<DeleteItem>}
@@ -210,6 +230,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 {Transaction} transaction
@@ -218,7 +258,7 @@ export const writeDeleteSet = (encoder, ds) => {
* @private
* @function
*/
export const readDeleteSet = (decoder, transaction, store) => {
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
@@ -265,6 +305,7 @@ export const readDeleteSet = (decoder, transaction, store) => {
}
}
if (unappliedDS.clients.size > 0) {
// TODO: no need for encoding+decoding ds anymore
const unappliedDSEncoder = encoding.createEncoder()
writeDeleteSet(unappliedDSEncoder, unappliedDS)
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))

View File

@@ -17,17 +17,23 @@ import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js'
export const generateNewClientId = random.uint32
/**
* A Yjs instance handles the state of shared data.
* @extends Observable<string>
*/
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()
this.clientID = random.uint32()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
/**
* @type {Map<string, AbstractType<YEvent>>}
*/
@@ -35,15 +41,14 @@ export class Doc extends Observable {
this.store = new StructStore()
/**
* @type {Transaction | null}
* @private
*/
this._transaction = null
/**
* @type {Array<Transaction>}
* @private
*/
this._transactionCleanups = []
}
/**
* Changes that happen inside of a transaction are bundled. This means that
* the observer fires _after_ the transaction is finished and that all changes
@@ -58,6 +63,7 @@ export class Doc extends Observable {
transact (f, origin = null) {
transact(this, f, origin)
}
/**
* Define a shared data type.
*
@@ -99,6 +105,7 @@ export class Doc extends Observable {
t._map = type._map
type._map.forEach(/** @param {Item?} n */ n => {
for (; n !== null; n = n.left) {
// @ts-ignore
n.parent = t
}
})
@@ -116,6 +123,7 @@ export class Doc extends Observable {
}
return type
}
/**
* @template T
* @param {string} name
@@ -127,6 +135,7 @@ export class Doc extends Observable {
// @ts-ignore
return this.get(name, YArray)
}
/**
* @param {string} name
* @return {YText}
@@ -137,6 +146,7 @@ export class Doc extends Observable {
// @ts-ignore
return this.get(name, YText)
}
/**
* @param {string} name
* @return {YMap<any>}
@@ -147,6 +157,7 @@ export class Doc extends Observable {
// @ts-ignore
return this.get(name, YMap)
}
/**
* @param {string} name
* @return {YXmlFragment}
@@ -157,15 +168,15 @@ export class Doc extends Observable {
// @ts-ignore
return this.get(name, YXmlFragment)
}
/**
* Emit `destroy` event and unregister all event handlers.
*
* @protected
*/
destroy () {
this.emit('destroyed', [true])
super.destroy()
}
/**
* @param {string} eventName
* @param {function} f
@@ -173,6 +184,7 @@ export class Doc extends Observable {
on (eventName, f) {
super.on(eventName, f)
}
/**
* @param {string} eventName
* @param {function} f

View File

@@ -81,7 +81,7 @@ export const readID = decoder =>
*/
export const findRootTypeKey = type => {
// @ts-ignore _y must be defined, otherwise unexpected case
for (let [key, value] of type.doc.share) {
for (const [key, value] of type.doc.share) {
if (value === type) {
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

@@ -1,14 +1,14 @@
import {
getItem,
createID,
writeID,
readID,
compareIDs,
getState,
findRootTypeKey,
Item,
createID,
ContentType,
followRedone,
ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
@@ -63,7 +63,7 @@ export class RelativePosition {
}
/**
* @param {Object} json
* @param {any} json
* @return {RelativePosition}
*
* @function
@@ -107,7 +107,7 @@ export const createRelativePosition = (type, item) => {
if (type._item === null) {
tname = findRootTypeKey(type)
} else {
typeid = type._item.id
typeid = createID(type._item.id.client, type._item.id.clock)
}
return new RelativePosition(typeid, tname, item)
}
@@ -222,19 +222,22 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
if (getState(store, rightID.client) <= rightID.clock) {
return null
}
const right = getItem(store, rightID)
const res = followRedone(store, rightID)
const right = res.item
if (!(right instanceof Item)) {
return null
}
index = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
index += n.length
type = /** @type {AbstractType<any>} */ (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
}
n = n.left
}
type = right.parent
} else {
if (tname !== null) {
type = doc.get(tname)
@@ -243,9 +246,9 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
// type does not exist yet
return null
}
const struct = getItem(store, typeID)
if (struct instanceof Item && struct.content instanceof ContentType) {
type = struct.content.type
const { item } = followRedone(store, typeID)
if (item instanceof Item && item.content instanceof ContentType) {
type = item.content.type
} else {
// struct is garbage collected
return null
@@ -255,9 +258,6 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
}
index = type._length
}
if (type._item !== null && type._item.deleted) {
return null
}
return createAbsolutePosition(type, index)
}

View File

@@ -1,35 +1,112 @@
import {
isDeleted,
DeleteSet, Item // eslint-disable-line
createDeleteSetFromStructStore,
getStateVector,
getItemCleanStart,
iterateDeletedStructs,
writeDeleteSet,
writeStateVector,
readDeleteSet,
readStateVector,
createDeleteSet,
createID,
getState,
Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class Snapshot {
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm state map
* @param {Map<number,number>} sv state map
*/
constructor (ds, sm) {
constructor (ds, sv) {
/**
* @type {DeleteSet}
* @private
*/
this.ds = ds
/**
* State Map
* @type {Map<number,number>}
* @private
*/
this.sm = sm
this.sv = sv
}
}
/**
* @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
@@ -38,5 +115,24 @@ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
* @function
*/
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

@@ -2,7 +2,7 @@
import {
GC,
splitItem,
GCRef, ItemRef, Transaction, ID, Item, AbstractStruct // eslint-disable-line
AbstractStruct, Transaction, ID, Item // eslint-disable-line
} from '../internals.js'
import * as math from 'lib0/math.js'
@@ -12,8 +12,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
export class StructStore {
constructor () {
/**
* @type {Map<number,Array<AbstractStruct>>}
* @private
* @type {Map<number,Array<GC|Item>>}
*/
this.clients = new Map()
/**
@@ -22,20 +21,17 @@ export class StructStore {
* We could shift the array of refs instead, but shift is incredible
* slow in Chrome for arrays with more than 100k elements
* @see tryResumePendingStructRefs
* @type {Map<number,{i:number,refs:Array<GCRef|ItemRef>}>}
* @private
* @type {Map<number,{i:number,refs:Array<GC|Item>}>}
*/
this.pendingClientsStructRefs = new Map()
/**
* Stack of pending structs waiting for struct dependencies
* Maximum length of stack is structReaders.size
* @type {Array<GCRef|ItemRef>}
* @private
* @type {Array<GC|Item>}
*/
this.pendingStack = []
/**
* @type {Array<decoding.Decoder>}
* @private
*/
this.pendingDeleteReaders = []
}
@@ -97,7 +93,7 @@ export const integretyCheck = store => {
/**
* @param {StructStore} store
* @param {AbstractStruct} struct
* @param {GC|Item} struct
*
* @private
* @function
@@ -118,7 +114,7 @@ export const addStruct = (store, struct) => {
/**
* Perform a binary search on a sorted array
* @param {Array<any>} structs
* @param {Array<Item|GC>} structs
* @param {number} clock
* @return {number}
*
@@ -128,10 +124,18 @@ export const addStruct = (store, struct) => {
export const findIndexSS = (structs, clock) => {
let left = 0
let right = structs.length - 1
let mid = structs[right]
let midclock = mid.id.clock
if (mid.id.clock === clock) {
return right
}
// @todo does it even make sense to pivot the search?
// If a good split misses, it might actually increase the time to find the correct item.
// Currently, the only advantage is that search with pivoting might find the item on the first try.
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = structs[midindex]
const midclock = mid.id.clock
mid = structs[midindex]
midclock = mid.id.clock
if (midclock <= clock) {
if (clock < midclock + mid.length) {
return midindex
@@ -140,6 +144,7 @@ export const findIndexSS = (structs, clock) => {
} else {
right = midindex - 1
}
midindex = math.floor((left + right) / 2)
}
// Always check state before looking for a struct in StructStore
// Therefore the case of not finding a struct is unexpected
@@ -151,14 +156,14 @@ export const findIndexSS = (structs, clock) => {
*
* @param {StructStore} store
* @param {ID} id
* @return {AbstractStruct}
* @return {GC|Item}
*
* @private
* @function
*/
export const find = (store, id) => {
/**
* @type {Array<AbstractStruct>}
* @type {Array<GC|Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
@@ -167,37 +172,39 @@ export const find = (store, id) => {
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
// @ts-ignore
export const getItem = (store, id) => find(store, id)
export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
/**
* @param {Transaction} transaction
* @param {Array<Item|GC>} structs
* @param {number} clock
*/
export const findIndexCleanStart = (transaction, structs, clock) => {
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.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanStart = (transaction, store, id) => {
const structs = /** @type {Array<Item>} */ (store.clients.get(id.client))
const index = findIndexSS(structs, id.clock)
let struct = structs[index]
if (struct.id.clock < id.clock && struct.constructor !== GC) {
struct = splitItem(transaction, struct, id.clock - struct.id.clock)
structs.splice(index + 1, 0, struct)
}
return struct
export const getItemCleanStart = (transaction, id) => {
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
return structs[findIndexCleanStart(transaction, structs, id.clock)]
}
/**
@@ -228,13 +235,40 @@ export const getItemCleanEnd = (transaction, store, id) => {
/**
* Replace `item` with `newitem` in store
* @param {StructStore} store
* @param {AbstractStruct} struct
* @param {AbstractStruct} newStruct
* @param {GC|Item} struct
* @param {GC|Item} newStruct
*
* @private
* @function
*/
export const replaceStruct = (store, struct, newStruct) => {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(struct.id.client))
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(struct.id.client))
structs[findIndexSS(structs, struct.id.clock)] = newStruct
}
/**
* Iterate over a range of structs
*
* @param {Transaction} transaction
* @param {Array<Item|GC>} structs
* @param {number} clockStart Inclusive start
* @param {number} len
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateStructs = (transaction, structs, clockStart, len, f) => {
if (len === 0) {
return
}
const clockEnd = clockStart + len
let index = findIndexCleanStart(transaction, structs, clockStart)
let struct
do {
struct = structs[index++]
if (clockEnd < struct.id.clock + struct.length) {
findIndexCleanStart(transaction, structs, clockEnd)
}
f(struct)
} while (index < structs.length && structs[index].id.clock < clockEnd)
}

View File

@@ -1,7 +1,6 @@
import {
getState,
createID,
writeStructsFromTransaction,
writeDeleteSet,
DeleteSet,
@@ -10,12 +9,17 @@ import {
findIndexSS,
callEventHandlerListeners,
Item,
ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
generateNewClientId,
createID,
GC, StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as map from 'lib0/map.js'
import * as math from 'lib0/math.js'
import * as set from 'lib0/set.js'
import * as logging from 'lib0/logging.js'
import { callAll } from 'lib0/function.js'
/**
* A transaction is created for every change on the Yjs model. It is possible
@@ -45,8 +49,9 @@ export class Transaction {
/**
* @param {Doc} doc
* @param {any} origin
* @param {boolean} local
*/
constructor (doc, origin) {
constructor (doc, origin, local) {
/**
* The Yjs instance.
* @type {Doc}
@@ -70,7 +75,7 @@ export class Transaction {
/**
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
*/
this.changed = new Map()
@@ -81,14 +86,23 @@ export class Transaction {
*/
this.changedParentTypes = new Map()
/**
* @type {Set<ID>}
* @private
* @type {Array<AbstractStruct>}
*/
this._mergeStructs = new Set()
this._mergeStructs = []
/**
* @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
}
}
@@ -117,22 +131,226 @@ export const nextID = transaction => {
return createID(y.clientID, getState(y.store, y.clientID))
}
/**
* 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 {Transaction} transaction
* @param {AbstractType<YEvent>} type
* @param {string|null} parentSub
*/
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
const item = type._item
if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) {
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
}
}
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType<any>} */ (right.parent)._map.get(right.parentSub) === right) {
/** @type {AbstractType<any>} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
const tryGcDeleteSet = (ds, store, gcFilter) => {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
struct.gc(store, false)
}
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
*/
const tryMergeDeleteSet = (ds, store) => {
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (ds, store, gcFilter) => {
tryGcDeleteSet(ds, store, gcFilter)
tryMergeDeleteSet(ds, store)
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
const mergeStructs = transaction._mergeStructs
try {
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
doc._transaction = null
doc.emit('beforeObserverCalls', [transaction, doc])
/**
* An array of event callbacks.
*
* Each callback is called even if the other ones throw errors.
*
* @type {Array<function():void>}
*/
const fs = []
// observe events on changed types
transaction.changed.forEach((subs, itemtype) =>
fs.push(() => {
if (itemtype._item === null || !itemtype._item.deleted) {
itemtype._callObserver(transaction, subs)
}
})
)
fs.push(() => {
// deep observe events
transaction.changedParentTypes.forEach((events, type) =>
fs.push(() => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
if (type._item === null || !type._item.deleted) {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
})
// 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
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) {
tryToMergeWithLeft(structs, i)
}
}
}
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
for (let i = 0; i < mergeStructs.length; i++) {
const { client, clock } = mergeStructs[i].id
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1)
}
if (replacedStructPos > 0) {
tryToMergeWithLeft(structs, replacedStructPos)
}
}
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
doc.clientID = generateNewClientId()
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const 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]
* @param {any} [origin=true]
*
* @private
* @function
*/
export const transact = (doc, f, origin = null) => {
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)
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
doc.emit('beforeTransaction', [doc._transaction, doc])
}
@@ -142,125 +360,13 @@ export const transact = (doc, f, origin = null) => {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
// Observer call may create new transactions for which we need to call the observers and do cleanup.
// We don't want to nest these calls, so we execute these calls one after another
for (let i = 0; i < transactionCleanups.length; i++) {
const transaction = transactionCleanups[i]
const store = transaction.doc.store
const ds = transaction.deleteSet
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
doc._transaction = null
doc.emit('beforeObserverCalls', [transaction, doc])
// emit change events on changed types
transaction.changed.forEach((subs, itemtype) => {
itemtype._callObserver(transaction, subs)
})
transaction.changedParentTypes.forEach((events, type) => {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
})
// we don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
})
doc.emit('afterTransaction', [transaction, doc])
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
// replace deleted items with ItemDeleted / GC
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct.deleted && struct instanceof Item) {
struct.gc(store, false)
}
}
}
}
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
// on all affected store.clients props, try to merge
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) {
tryToMergeWithLeft(structs, i)
}
}
}
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
for (const mid of transaction._mergeStructs) {
const client = mid.client
const clock = mid.clock
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1)
}
if (replacedStructPos > 0) {
tryToMergeWithLeft(structs, replacedStructPos)
}
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const updateMessage = computeUpdateMessageFromTransaction(transaction)
if (updateMessage !== null) {
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
}
}
}
doc._transactionCleanups = []
// We don't want to nest these calls, so we execute these calls one after
// another.
// Also we need to ensure that all cleanups are called, even if the
// observes throw errors.
// This file is full of hacky try {} finally {} blocks to ensure that an
// event can throw errors and also that the cleanup is called.
cleanupTransactions(transactionCleanups, 0)
}
}
}

View File

@@ -1,202 +1,293 @@
// @ts-nocheck
import {
isParentOf,
mergeDeleteSets,
iterateDeletedStructs,
keepItem,
transact,
createID,
transact
redoItem,
iterateStructs,
isParentOf,
followRedone,
getItemCleanStart,
getState,
ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js'
/**
* @private
*/
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
}
}
import * as time from 'lib0/time.js'
import { Observable } from 'lib0/observable.js'
/**
* @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 {
class StackItem {
/**
* @param {YType} scope The scope on which to listen for changes.
* @param {Object} options Optionally provided configuration.
* @param {DeleteSet} ds
* @param {Map<number,number>} beforeState
* @param {Map<number,number>} afterState
*/
constructor (scope, options = {}) {
this.options = options
this._bindings = new Set(options.bindings)
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout
this._undoBuffer = []
this._redoBuffer = []
this._scope = scope
this._undoing = false
this._redoing = false
this._lastTransactionWasUndo = false
const doc = scope.doc
this.y = doc
let bindingInfos
doc.on('beforeTransaction', (y, transaction, remote) => {
if (!remote) {
// Store binding information before transaction is executed
// By restoring the binding information, we can make sure that the state
// before the transaction can be recovered
bindingInfos = new Map()
this._bindings.forEach(binding => {
bindingInfos.set(binding, binding._getUndoStackInfo())
})
}
})
doc.on('afterTransaction', (y, transaction, remote) => {
if (!remote && transaction.changedParentTypes.has(scope)) {
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos)
if (!this._undoing) {
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null
if (
this._redoing === false &&
this._lastTransactionWasUndo === false &&
lastUndoOp !== null &&
((options.captureTimeout < 0) || (reverseOperation.created.getTime() - lastUndoOp.created.getTime()) <= options.captureTimeout)
) {
lastUndoOp.created = reverseOperation.created
if (reverseOperation.toState !== null) {
lastUndoOp.toState = reverseOperation.toState
if (lastUndoOp.fromState === null) {
lastUndoOp.fromState = reverseOperation.fromState
constructor (ds, beforeState, afterState) {
this.ds = ds
this.beforeState = beforeState
this.afterState = afterState
/**
* Use this to save and restore metadata like selection range
*/
this.meta = new Map()
}
}
/**
* @param {UndoManager} undoManager
* @param {Array<StackItem>} stack
* @param {string} eventType
* @return {StackItem?}
*/
const popStackItem = (undoManager, stack, eventType) => {
/**
* Whether a change happened
* @type {StackItem?}
*/
let result = null
const doc = undoManager.doc
const scope = undoManager.scope
transact(doc, transaction => {
while (stack.length > 0 && result === null) {
const store = doc.store
const stackItem = /** @type {StackItem} */ (stack.pop())
/**
* @type {Set<Item>}
*/
const itemsToRedo = new Set()
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
let performedChange = false
stackItem.afterState.forEach((endClock, client) => {
const startClock = stackItem.beforeState.get(client) || 0
const len = endClock - startClock
// @todo iterateStructs should not need the structs parameter
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
if (startClock !== endClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
// this must be executed before deleted structs are iterated.
getItemCleanStart(transaction, createID(client, startClock))
if (endClock < getState(doc.store, client)) {
getItemCleanStart(transaction, createID(client, endClock))
}
iterateStructs(transaction, structs, startClock, len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > len) {
getItemCleanStart(transaction, createID(item.id.client, endClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
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 => {
const id = struct.id
const clock = id.clock
const client = id.client
const startClock = stackItem.beforeState.get(client) || 0
const endClock = stackItem.afterState.get(client) || 0
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(clock >= startClock && clock < endClock)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
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
const afterState = transaction.afterState
const now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.afterState = afterState
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
}
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])
})
}
/**
* Enforce that the next change is created as a separate item in the undo stack
*
* @private
* @function
*/
flushChanges () {
this._lastTransactionWasUndo = true
clear () {
this.doc.transact(transaction => {
/**
* @param {StackItem} stackItem
*/
const clearItem = stackItem => {
iterateDeletedStructs(transaction, stackItem.ds, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
this.undoStack.forEach(clearItem)
this.redoStack.forEach(clearItem)
})
this.undoStack = []
this.redoStack = []
}
/**
* 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 () {
this._undoing = true
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer)
this._undoing = false
return performedUndo
this.undoing = true
let res
try {
res = popStackItem(this, this.undoStack, 'undo')
} finally {
this.undoing = false
}
return res
}
/**
* Redo the last locally created change.
* Redo last undo operation.
*
* @private
* @function
* @return {StackItem?} Returns StackItem if a change was applied
*/
redo () {
this._redoing = true
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer)
this._redoing = false
return performedRedo
this.redoing = true
let res
try {
res = popStackItem(this, this.redoStack, 'redo')
} finally {
this.redoing = false
}
return res
}
}

View File

@@ -1,9 +1,12 @@
import {
isDeleted,
AbstractType, Transaction, AbstractStruct // eslint-disable-line
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set.js'
import * as array from 'lib0/array.js'
/**
* YEvent describes the changes on a YType.
*/
@@ -28,6 +31,10 @@ export class YEvent {
* @type {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.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
@@ -59,12 +68,121 @@ export class YEvent {
/**
* Check if a struct is added by this event.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
adds (struct) {
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)
}
}
/**
@@ -93,7 +211,7 @@ const getPathTo = (parent, child) => {
} else {
// parent is array-ish
let i = 0
let c = child._item.parent._start
let c = /** @type {AbstractType<any>} */ (child._item.parent)._start
while (c !== child._item && c !== null) {
if (!c.deleted) {
i++
@@ -102,7 +220,7 @@ const getPathTo = (parent, child) => {
}
path.unshift(i)
}
child = child._item.parent
child = /** @type {AbstractType<any>} */ (child._item.parent)
}
return path
}

View File

@@ -16,17 +16,17 @@
import {
findIndexSS,
GCRef,
ItemRef,
writeID,
createID,
readID,
getState,
createID,
getStateVector,
readDeleteSet,
readAndApplyDeleteSet,
writeDeleteSet,
createDeleteSetFromStructStore,
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line
transact,
readItem,
Doc, Transaction, GC, Item, StructStore, ID // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
@@ -35,7 +35,7 @@ import * as binary from 'lib0/binary.js'
/**
* @param {encoding.Encoder} encoder
* @param {Array<AbstractStruct>} structs All structs by `client`
* @param {Array<GC|Item>} structs All structs by `client`
* @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)`
*
@@ -49,35 +49,12 @@ const writeStructs = (encoder, structs, client, clock) => {
writeID(encoder, createID(client, clock))
const firstStruct = structs[startNewStructs]
// write first struct with an offset
firstStruct.write(encoder, clock - firstStruct.id.clock, 0)
firstStruct.write(encoder, clock - firstStruct.id.clock)
for (let i = startNewStructs + 1; i < structs.length; i++) {
structs[i].write(encoder, 0, 0)
structs[i].write(encoder, 0)
}
}
/**
* @param {decoding.Decoder} decoder
* @param {number} numOfStructs
* @param {ID} nextID
* @return {Array<GCRef|ItemRef>}
*
* @private
* @function
*/
const readStructRefs = (decoder, numOfStructs, nextID) => {
/**
* @type {Array<GCRef|ItemRef>}
*/
const refs = []
for (let i = 0; i < numOfStructs; i++) {
const info = decoding.readUint8(decoder)
const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, nextID, info) : new ItemRef(decoder, nextID, info)
nextID = createID(nextID.client, nextID.clock + ref.length)
refs.push(ref)
}
return refs
}
/**
* @param {encoding.Encoder} encoder
* @param {StructStore} store
@@ -102,7 +79,9 @@ export const writeClientsStructs = (encoder, store, _sm) => {
})
// write # states that were updated
encoding.writeVarUint(encoder, sm.size)
sm.forEach((clock, client) => {
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
// @ts-ignore
writeStructs(encoder, store.clients.get(client), client, clock)
})
@@ -110,22 +89,32 @@ export const writeClientsStructs = (encoder, store, _sm) => {
/**
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @return {Map<number,Array<GCRef|ItemRef>>}
* @param {Map<number,Array<GC|Item>>} clientRefs
* @param {Doc} doc
* @return {Map<number,Array<GC|Item>>}
*
* @private
* @function
*/
export const readClientsStructRefs = decoder => {
/**
* @type {Map<number,Array<GCRef|ItemRef>>}
*/
const clientRefs = new Map()
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
const numOfStateUpdates = decoding.readVarUint(decoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder)
const nextID = readID(decoder)
const refs = readStructRefs(decoder, numberOfStructs, nextID)
clientRefs.set(nextID.client, refs)
const nextIdClient = nextID.client
let nextIdClock = nextID.clock
/**
* @type {Array<GC|Item>}
*/
const refs = []
clientRefs.set(nextIdClient, refs)
for (let i = 0; i < numberOfStructs; i++) {
const info = decoding.readUint8(decoder)
const id = createID(nextIdClient, nextIdClock)
const struct = (binary.BITS5 & info) === 0 ? new GC(id, decoding.readVarUint(decoder)) : readItem(decoder, id, info, doc)
refs.push(struct)
nextIdClock += struct.length
}
}
return clientRefs
}
@@ -169,17 +158,19 @@ const resumeStructIntegration = (transaction, store) => {
}
}
const ref = stack[stack.length - 1]
const m = ref._missing
const client = ref.id.client
const refID = ref.id
const client = refID.client
const refClock = refID.clock
const localClock = getState(store, client)
const offset = ref.id.clock < localClock ? localClock - ref.id.clock : 0
if (ref.id.clock + offset !== localClock) {
const offset = refClock < localClock ? localClock - refClock : 0
const missing = ref.getMissing(transaction, store)
if (refClock + offset !== localClock) {
// A previous message from this client is missing
// check if there is a pending structRef with a smaller clock and switch them
const structRefs = clientsStructRefs.get(client)
if (structRefs !== undefined) {
const r = structRefs.refs[structRefs.i]
if (r.id.clock < ref.id.clock) {
if (r.id.clock < refClock) {
// put ref with smaller clock on stack instead and continue
structRefs.refs[structRefs.i] = ref
stack[stack.length - 1] = r
@@ -192,27 +183,21 @@ const resumeStructIntegration = (transaction, store) => {
// wait until missing struct is available
return
}
while (m.length > 0) {
const missing = m[m.length - 1]
if (getState(store, missing.client) <= missing.clock) {
const client = missing.client
// get the struct reader that has the missing struct
const structRefs = clientsStructRefs.get(client)
if (structRefs === undefined) {
// This update message causally depends on another update message.
return
}
stack.push(structRefs.refs[structRefs.i++])
if (structRefs.i === structRefs.refs.length) {
clientsStructRefs.delete(client)
}
break
if (missing) {
const client = missing.client
// get the struct reader that has the missing struct
const structRefs = clientsStructRefs.get(client)
if (structRefs === undefined) {
// This update message causally depends on another update message.
return
}
ref._missing.pop()
}
if (m.length === 0) {
stack.push(structRefs.refs[structRefs.i++])
if (structRefs.i === structRefs.refs.length) {
clientsStructRefs.delete(client)
}
} else {
if (offset < ref.length) {
ref.toStruct(transaction, store, offset).integrate(transaction)
ref.integrate(transaction, offset)
}
stack.pop()
}
@@ -230,7 +215,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
const pendingReaders = store.pendingDeleteReaders
store.pendingDeleteReaders = []
for (let i = 0; i < pendingReaders.length; i++) {
readDeleteSet(pendingReaders[i], transaction, store)
readAndApplyDeleteSet(pendingReaders[i], transaction, store)
}
}
@@ -245,7 +230,7 @@ export const writeStructsFromTransaction = (encoder, transaction) => writeClient
/**
* @param {StructStore} store
* @param {Map<number, Array<GCRef|ItemRef>>} clientsStructsRefs
* @param {Map<number, Array<GC|Item>>} clientsStructsRefs
*
* @private
* @function
@@ -281,7 +266,8 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
* @function
*/
export const readStructs = (decoder, transaction, store) => {
const clientsStructRefs = readClientsStructRefs(decoder)
const clientsStructRefs = new Map()
readClientsStructRefs(decoder, clientsStructRefs, transaction.doc)
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
resumeStructIntegration(transaction, store)
tryResumePendingDeleteReaders(transaction, store)
@@ -299,10 +285,10 @@ export const readStructs = (decoder, transaction, store) => {
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
ydoc.transact(transaction => {
transact(ydoc, transaction => {
readStructs(decoder, transaction, ydoc.store)
readDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin)
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin, false)
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
@@ -381,6 +367,22 @@ export const readStateVector = decoder => {
*/
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`.
*
@@ -389,16 +391,7 @@ export const decodeStateVector = decodedState => readStateVector(decoding.create
*
* @function
*/
export const writeDocumentStateVector = (encoder, doc) => {
encoding.writeVarUint(encoder, doc.store.clients.size)
doc.store.clients.forEach((structs, client) => {
const struct = structs[structs.length - 1]
const id = struct.id
encoding.writeVarUint(encoder, id.client)
encoding.writeVarUint(encoder, id.clock + struct.length)
})
return encoder
}
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
/**
* Encode State as Uint8Array.

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`.
*
* @param {AbstractType<any>} parent
* @param {AbstractType<any>} child
* @param {Item|null} child
* @return {Boolean} Whether `parent` is a parent of `child`.
*
* @private
* @function
*/
export const isParentOf = (parent, child) => {
while (child._item !== null) {
if (child === parent) {
while (child !== null) {
if (child.parent === parent) {
return true
}
child = child._item.parent
child = /** @type {AbstractType<any>} */ (child.parent)._item
}
return false
}

View File

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

View File

@@ -8,19 +8,21 @@ import {
readContentJSON,
readContentEmbed,
readContentType,
readContentFormat
readContentFormat,
readContentAny
} from '../src/internals.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 8)
t.assert(contentRefs.length === 9)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
t.assert(contentRefs[4] === readContentString)
t.assert(contentRefs[5] === readContentEmbed)
t.assert(contentRefs[6] === readContentFormat)
t.assert(contentRefs[7] === readContentType)
t.assert(contentRefs[8] === readContentAny)
}

View File

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

View File

@@ -12,6 +12,7 @@ import * as prng from 'lib0/prng.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as syncProtocol from 'y-protocols/sync.js'
import * as object from 'lib0/object.js'
export * from '../src/internals.js'
/**
@@ -55,6 +56,7 @@ export class TestYInstance extends Doc {
})
this.connect()
}
/**
* Disconnect from TestConnector.
*/
@@ -62,6 +64,7 @@ export class TestYInstance extends Doc {
this.receiving = new Map()
this.tc.onlineConns.delete(this)
}
/**
* Append yourself to the list of known Y instances in testconnector.
* Also initiate sync with all clients.
@@ -83,6 +86,7 @@ export class TestYInstance extends Doc {
})
}
}
/**
* Receive a message from another client. This message is only appended to the list of receiving messages.
* TestConnector decides when this client actually reads this message.
@@ -124,6 +128,7 @@ export class TestConnector {
*/
this.prng = gen
}
/**
* Create a new Y instance and add it to the list of connections
* @param {number} clientID
@@ -131,6 +136,7 @@ export class TestConnector {
createY (clientID) {
return new TestYInstance(this, clientID)
}
/**
* Choose random connection and flush a random message from a random sender.
*
@@ -162,6 +168,7 @@ export class TestConnector {
}
return false
}
/**
* @return {boolean} True iff this function actually flushed something
*/
@@ -172,16 +179,20 @@ export class TestConnector {
}
return didSomething
}
reconnectAll () {
this.allConns.forEach(conn => conn.connect())
}
disconnectAll () {
this.allConns.forEach(conn => conn.disconnect())
}
syncAll () {
this.reconnectAll()
this.flushAllMessages()
}
/**
* @return {boolean} Whether it was possible to disconnect a randon connection.
*/
@@ -192,6 +203,7 @@ export class TestConnector {
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
return true
}
/**
* @return {boolean} Whether it was possible to reconnect a random connection.
*/
@@ -266,13 +278,16 @@ export const compare = users => {
t.assert(u.store.pendingClientsStructRefs.size === 0)
}
// Test Array iterator
t.compare(userArrayValues[0], Array.from(users[0].getArray('array').toJSON()))
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 (let [k, v] of users[0].getMap('map')) {
for (const [k, v] of users[0].getMap('map')) {
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
}
t.compare(userMapValues[0], mapRes)
@@ -282,7 +297,7 @@ export const compare = users => {
t.compare(userArrayValues[i], userArrayValues[i + 1])
t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1])
t.compare(userTextValues[i].map(/** @param {any} a */ a => a.insert).join('').length, users[i].getText('text').length)
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1])
t.compare(getStateVector(users[i].store), getStateVector(users[i + 1].store))
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
@@ -315,6 +330,7 @@ export const compareStructStores = (ss1, ss2) => {
s1.constructor !== s2.constructor ||
!Y.compareIDs(s1.id, s2.id) ||
s1.deleted !== s2.deleted ||
// @ts-ignore
s1.length !== s2.length
) {
t.fail('Structs dont match')
@@ -375,7 +391,7 @@ export const compareDS = (ds1, ds2) => {
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng
const result = init(tc, { users: 5 }, initTestObject || (() => null))
const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result
for (let i = 0; i < iterations; i++) {
if (prng.int31(gen, 0, 100) <= 2) {

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

@@ -3,6 +3,7 @@ import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
/**
* @param {t.TestCase} tc
@@ -191,6 +192,33 @@ export const testInsertAndDeleteEventsForTypes = tc => {
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
*/
@@ -199,7 +227,7 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
/**
* @type {Array<Object<string,any>>}
*/
let events = []
const events = []
array0.observe(e => {
events.push(e)
})
@@ -210,6 +238,24 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
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
*/
@@ -272,7 +318,7 @@ export const testIteratingArrayContainingTypes = tc => {
arr.push([map])
}
let cnt = 0
for (let item of arr) {
for (const item of arr) {
t.assert(item.get('value') === cnt++, 'value is correct')
}
y.destroy()
@@ -317,12 +363,12 @@ const arrayTransactions = [
var length = yarray.length
if (length > 0) {
var somePos = prng.int31(gen, 0, length - 1)
var delLength = prng.int31(gen, 1, Math.min(2, length - somePos))
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
var type = yarray.get(somePos)
if (type.length > 0) {
somePos = prng.int31(gen, 0, type.length - 1)
delLength = prng.int31(gen, 0, Math.min(2, type.length - somePos))
delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength)
}
} else {

View File

@@ -8,6 +8,33 @@ import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
/**
* @param {t.TestCase} tc
*/
export const testMapHavingIterableAsConstructorParamTests = tc => {
const { map0 } = init(tc, { users: 1 })
const m1 = new Y.Map(Object.entries({ number: 1, string: 'hello' }))
map0.set('m1', m1)
t.assert(m1.get('number') === 1)
t.assert(m1.get('string') === 'hello')
const m2 = new Y.Map([
['object', { x: 1 }],
['boolean', true]
])
map0.set('m2', m2)
t.assert(m2.get('object').x === 1)
t.assert(m2.get('boolean') === true)
const m3 = new Y.Map([...m1, ...m2])
map0.set('m3', m3)
t.assert(m3.get('number') === 1)
t.assert(m3.get('string') === 'hello')
t.assert(m3.get('object').x === 1)
t.assert(m3.get('boolean') === true)
}
/**
* @param {t.TestCase} tc
*/
@@ -33,6 +60,7 @@ export const testBasicMapTests = tc => {
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
t.assert(map0.size === 6, 'client 0 map has correct size')
users[2].connect()
testConnector.flushAllMessages()
@@ -43,6 +71,7 @@ export const testBasicMapTests = tc => {
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
t.assert(map1.size === 6, 'client 1 map has correct size')
// compare disconnected user
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
@@ -66,7 +95,7 @@ export const testGetAndSetOfMapProperty = tc => {
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
t.assert(u.get('undefined') === undefined, 'undefined')
@@ -108,7 +137,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
map0.set('stuff', 'stuffy')
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
}
@@ -123,13 +152,27 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c1')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSizeAndDeleteOfMapProperty = tc => {
const { map0 } = init(tc, { users: 1 })
map0.set('stuff', 'c0')
map0.set('otherstuff', 'c1')
t.assert(map0.size === 2, `map size is ${map0.size} expected 2`)
map0.delete('stuff')
t.assert(map0.size === 1, `map size after delete is ${map0.size}, expected 1`)
map0.delete('otherstuff')
t.assert(map0.size === 0, `map size after delete is ${map0.size}, expected 0`)
}
/**
* @param {t.TestCase} tc
*/
@@ -139,7 +182,7 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
map1.set('stuff', 'c1')
map1.delete('stuff')
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
@@ -156,7 +199,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c3')
}
@@ -179,7 +222,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
map3.set('stuff', 'c3')
map3.delete('stuff')
testConnector.flushAllMessages()
for (let user of users) {
for (const user of users) {
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
@@ -292,6 +335,104 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
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
*/
@@ -332,12 +473,12 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
*/
const mapTransactions = [
function set (user, gen) {
let key = prng.oneOf(gen, ['one', 'two'])
const key = prng.oneOf(gen, ['one', 'two'])
var value = prng.utf16String(gen)
user.getMap('map').set(key, value)
},
function setType (user, gen) {
let key = prng.oneOf(gen, ['one', 'two'])
const key = prng.oneOf(gen, ['one', 'two'])
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
user.getMap('map').set(key, type)
if (type instanceof Y.Array) {
@@ -347,7 +488,7 @@ const mapTransactions = [
}
},
function _delete (user, gen) {
let key = prng.oneOf(gen, ['one', 'two'])
const key = prng.oneOf(gen, ['one', 'two'])
user.getMap('map').delete(key)
}
]
@@ -356,7 +497,7 @@ const mapTransactions = [
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests10 = tc => {
applyRandomTests(tc, mapTransactions, 10)
applyRandomTests(tc, mapTransactions, 3)
}
/**

View File

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

View File

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

View File

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