Compare commits

...

541 Commits

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

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

For example we could set structs[i] = null and filter the structs after merging is done.
2019-04-28 17:20:35 +02:00
Kevin Jahns
c9dda245bf v13 api docs 2019-04-28 02:53:25 +02:00
Kevin Jahns
1417470156 update demos link 2019-04-27 03:44:48 +02:00
Kevin Jahns
584e5dfd40 Link to v13 docs from README 2019-04-27 03:35:44 +02:00
Kevin Jahns
805acbb9f5 13.0.0-80 2019-04-26 19:55:14 +02:00
Kevin Jahns
32c4c09072 update parent._map when splitting an item 2019-04-26 19:54:00 +02:00
Kevin Jahns
8c5a06bbf8 fix gc when item is deleted in observer call 2019-04-26 18:37:38 +02:00
Kevin Jahns
a336cc167c order observer and transaction cleanups after one another 2019-04-26 13:31:00 +02:00
Kevin Jahns
21d86cd2be Delete all children of ItemType when it is deleted 2019-04-26 12:29:28 +02:00
Kevin Jahns
1d0f9faa91 AbstractItem.mergeWith helper outsourced into separate function 2019-04-24 18:10:33 +02:00
Kevin Jahns
45237571b7 gc more efficiently 2019-04-23 20:51:32 +02:00
Kevin Jahns
bb6f6cd141 13.0.0-79 2019-04-20 00:03:30 +02:00
Kevin Jahns
729c1f16b8 fix test provider 2019-04-20 00:02:40 +02:00
Kevin Jahns
b6059704aa update dependencies 2019-04-20 00:00:09 +02:00
Kevin Jahns
fa3c92f44c change parameter order of transaction events 2019-04-19 23:36:00 +02:00
Kevin Jahns
cd82de7742 lint 2019-04-12 20:08:38 +02:00
Kevin Jahns
07a6a0044b simplify exposed APi 2019-04-12 20:04:07 +02:00
Kevin Jahns
4582832a71 rework intro 2019-04-12 14:24:31 +02:00
Kevin Jahns
07ac1d03e3 fix jsdoc 2019-04-11 23:34:56 +02:00
Kevin Jahns
cbcf1facb8 remove todo.md 2019-04-11 17:35:09 +02:00
Kevin Jahns
31ff7ac78c improve jsdoc comments 2019-04-11 13:22:59 +02:00
Kevin Jahns
ed3b31e58f jsdoc fixes 2019-04-11 00:49:07 +02:00
Kevin Jahns
759ecb21f7 rename transaction._replacedItems to transaction._mergeStructs 2019-04-11 00:31:43 +02:00
Kevin Jahns
9c29d820c8 rename AbstractRef to AbstractStructRef 2019-04-11 00:26:42 +02:00
Kevin Jahns
2ef11a5344 splitting an item must always happen inside a transaction, because we always need to check if we can merge it back 2019-04-11 00:23:08 +02:00
Kevin Jahns
9fe47e98d5 type._map points to the last element instead to enable merging of deletes in Map 2019-04-10 21:01:59 +02:00
Kevin Jahns
654510f3ff read struct refs as array 2019-04-10 18:52:22 +02:00
Kevin Jahns
52ec698635 implement some of the commented todos 2019-04-09 04:01:37 +02:00
Kevin Jahns
1b06f59d1c fixed remaining tests 2019-04-09 00:48:24 +02:00
Kevin Jahns
12bcc4d080 fix remaining random tests 2019-04-09 00:31:28 +02:00
Kevin Jahns
e1a9f314a7 fixed part of split/merge logic 2019-04-08 13:41:28 +02:00
Kevin Jahns
7a111de186 refactor read/write of structs 2019-04-07 23:08:08 +02:00
Kevin Jahns
90b3fa9dd9 fixed merging and adapted writeStructs to write end of message 2019-04-07 12:47:04 +02:00
Kevin Jahns
c635963747 Compare origin ids in item.integrate 2019-04-06 15:55:20 +02:00
Kevin Jahns
1b17b5e400 fixed 10 tests 2019-04-06 13:00:32 +02:00
Kevin Jahns
61d9d96d15 fix replace with delete 2019-04-05 21:06:43 +02:00
Kevin Jahns
7d0c048708 Items accept origins as IDs 2019-04-05 19:46:18 +02:00
Kevin Jahns
8a7416ad50 Create Structs based on offset, if necessary
implement offset parameter in Ref.toStruct
2019-04-05 12:38:02 +02:00
Kevin Jahns
e56899a02c after refactor - some tests are working again 2019-04-05 00:37:09 +02:00
Kevin Jahns
30bf3742c9 add internals file and use it to organize imports 2019-04-04 19:35:38 +02:00
Kevin Jahns
8dbd2c4696 restructure EventHandler 2019-04-04 13:50:00 +02:00
Kevin Jahns
6578727c9c fixed all type issues 2019-04-03 13:23:27 +02:00
Kevin Jahns
92ca001cdc implement getMap, getArray, getXml, .. 2019-04-03 03:08:10 +02:00
Kevin Jahns
415de1cc4c all YArray.tests type fixes 2019-04-03 02:30:44 +02:00
Kevin Jahns
e23582b1cd more type fixes and rethinking writeStructs 2019-04-02 23:08:58 +02:00
Kevin Jahns
73c28952c2 fix all types but yxmlelement 2019-03-30 11:00:54 +01:00
Kevin Jahns
1bc1e88d6a fix y-text 2019-03-30 01:08:09 +01:00
Kevin Jahns
c188f813a4 fixed YMap 2019-03-29 13:49:13 +01:00
Kevin Jahns
ff981a8697 fixed YArray 2019-03-29 01:03:02 +01:00
Kevin Jahns
d9ab593b07 prelim refactor commit 2019-03-26 01:14:15 +01:00
Kevin Jahns
293527e62b fix a few tsc errors (96 remaining) 2019-03-13 02:15:43 +01:00
Kevin Jahns
5a42a94cf4 add typescript to lint script 2019-03-13 01:49:51 +01:00
Kevin Jahns
040808300c clean up build script - no more warnings 2019-03-13 01:16:31 +01:00
Kevin Jahns
57975d409e cleanup dependencies 2019-03-13 00:22:38 +01:00
Kevin Jahns
306b2c64f3 Merge branch 'master' of https://github.com/y-js/yjs 2019-03-13 00:04:42 +01:00
Kevin Jahns
585265e9a5 refactor and remove dependency circles 2019-03-13 00:04:19 +01:00
Kevin Jahns
777ae9503a Merge pull request #142 from mtn/mtn-patch-1
Correct typo in README example
2019-03-12 03:36:51 +01:00
Kevin Jahns
4c1798e5fa fix all remaining tests (xml tests) 2019-03-12 01:42:51 +01:00
Kevin Jahns
f4d85e2a3e fix y-text tests 2019-03-12 01:22:06 +01:00
Kevin Jahns
a0f0c9c377 testing: use lib0.testing.compare to compare Maps and sets 2019-03-11 18:34:50 +01:00
Kevin Jahns
95ec2a435a fix remaining y-map tests 2019-03-11 17:52:51 +01:00
Kevin Jahns
da9836fe59 added all y-map tests 2019-03-11 12:31:37 +01:00
Kevin Jahns
3a7411f9e8 reworked some ymap tests (a few are running again) 2019-03-11 00:00:41 +01:00
Kevin Jahns
39cee7c6e7 refix array tests and switch to lib0 2019-03-10 23:26:53 +01:00
Kevin Jahns
0a5753c191 decode items before they are decoded. fixes lots of y-array tests 2019-03-07 18:57:39 +01:00
Kevin Jahns
76b7d0b651 fixed some issues in random tests 2019-03-06 13:29:16 +01:00
Kevin Jahns
99e3e95a00 added remaining y-array tests (random still failing) 2019-03-05 14:00:31 +01:00
Kevin Jahns
93ee4ee287 converted first y-array test to funlib/testing 2019-03-04 14:28:18 +01:00
Kevin Jahns
c5cc403a29 update test commands 2019-03-01 23:45:09 +01:00
Kevin Jahns
75f4a0a5f0 restructuring the project 2019-03-01 23:28:11 +01:00
Michael Noronha
591df5c00a Correct typo in README example
/bower_components -> ./bower_components
2019-02-23 17:30:05 -06:00
Kevin Jahns
f6b4819ae3 prosemirror: implement isChangeOrigin in state 2019-01-31 09:50:52 +01:00
Kevin Jahns
d483d9cc83 13.0.0-78 2019-01-29 01:38:40 +01:00
Kevin Jahns
453407b93d fix connection status and awareness info when disconnected (ws-provider) 2019-01-29 01:38:23 +01:00
Kevin Jahns
e699f92333 13.0.0-77 2019-01-29 00:56:15 +01:00
Kevin Jahns
6ff47719ef Merge branch 'master' of github.com:y-js/yjs 2019-01-29 00:55:22 +01:00
Kevin Jahns
3a0694c35c added utilities to make and recover snapshots 2019-01-29 00:54:58 +01:00
Kevin Jahns
74e5243742 Merge pull request #138 from calibr/yjs
updating YArray's iterator to iterate Types correctly
2019-01-23 11:00:37 +01:00
calibr
dcf43b9797 switch to the next item in YArray's iterator after processing a Type item 2019-01-16 03:12:58 +03:00
Kevin Jahns
77e479c03b working on snapshotting and version history 2019-01-09 23:54:36 +01:00
Kevin Jahns
ec58a99748 add clock vector to awareness protocol 2018-12-22 15:51:09 +01:00
Kevin Jahns
f1eb66655b implemented leveldb persistence for websocket server 2018-12-22 13:45:59 +01:00
Kevin Jahns
7f4ae9fe14 implemented codemirror binding with cursor support 2018-12-21 13:51:38 +01:00
Kevin Jahns
c0ba56a21f update v13 docs 2018-12-19 01:12:29 +01:00
Kevin Jahns
4063e28b5e 13.0.0-76 2018-12-11 20:19:07 +01:00
Kevin Jahns
b6f7cd7869 fix broadcast channel communication 2018-12-11 20:18:11 +01:00
Kevin Jahns
1a79e429ed 13.0.0-75 2018-12-11 19:49:50 +01:00
Kevin Jahns
04066a5678 permission protocol + reduce circular dependencies 2018-12-11 19:49:21 +01:00
Kevin Jahns
e09ef15349 13.0.0-74 2018-12-04 18:07:04 +01:00
Kevin Jahns
3d70eee959 item: increase parent length only if parentSub=null 2018-12-03 23:09:59 +01:00
Kevin Jahns
582095e5a3 improved granularity of prosemirror binding 2018-12-03 17:09:00 +01:00
Kevin Jahns
c9ea3a412e more efficient length computing 2018-11-28 13:20:14 +01:00
Kevin Jahns
a2c51c36e9 implement generic broadcastchannel and apply it to websocket provider 2018-11-27 18:29:25 +01:00
Kevin Jahns
ab3dba5b06 add source file info to examples 2018-11-27 15:24:58 +01:00
Kevin Jahns
3ddff186c2 back to .js extension 2018-11-27 14:59:24 +01:00
Kevin Jahns
9bd199a6e7 add description to each example 2018-11-27 00:57:15 +01:00
Kevin Jahns
01d0825ae6 13.0.0-73 2018-11-26 17:14:48 +01:00
Kevin Jahns
e2f98525d2 clean examples build 2018-11-26 17:14:45 +01:00
Kevin Jahns
70a0a03130 no start content in prosemirror example 2018-11-26 16:59:01 +01:00
Kevin Jahns
656d85c62e add dom example 2018-11-26 16:06:17 +01:00
Kevin Jahns
e168dd48fb proper api endpoints for examples 2018-11-26 14:54:46 +01:00
Kevin Jahns
12d43199d5 add http listener to websocket-server 2018-11-26 13:08:23 +01:00
Kevin Jahns
539fa8b21d examples use hosted server 2018-11-26 02:13:06 +01:00
Kevin Jahns
f572f94586 port support 2018-11-25 23:41:17 +01:00
Kevin Jahns
c12d00b227 mjs nodejs support 2018-11-25 22:39:50 +01:00
Kevin Jahns
e4a5f2caec jsdoc fixes 2018-11-25 05:43:18 +01:00
Kevin Jahns
9f9f465238 update logo link 2018-11-25 04:50:23 +01:00
Kevin Jahns
8450ff86d7 make npm build ready for netlify 2018-11-25 04:41:52 +01:00
Kevin Jahns
70139262c5 add rollup-cli as dependency 2018-11-25 03:36:06 +01:00
Kevin Jahns
9c0da271eb large scale refactoring 2018-11-25 03:17:00 +01:00
Kevin Jahns
ade3e1949d update cdn destination. closes #128 2018-11-20 15:03:28 +01:00
Kevin Jahns
eec63a008f 13.0.0-72 2018-11-20 03:53:55 +01:00
Kevin Jahns
52abcdd043 fix all tests 2018-11-16 12:33:41 +01:00
Kevin Jahns
f94653424a add prosemirror tests 2018-11-14 07:20:06 +01:00
Kevin Jahns
d67a794e2c 13.0.0-71 2018-11-09 01:49:59 +01:00
Kevin Jahns
60318083a6 make websocket-server a binary and add bindings and provider to npm package 2018-11-09 01:49:43 +01:00
Kevin Jahns
7607070452 13.0.0-70 2018-11-09 01:24:06 +01:00
Kevin Jahns
28fb7b6e9c remove logging in prosemirror binding 2018-11-09 01:23:16 +01:00
Kevin Jahns
aafe15757f implemented awareness protocol and added cursor support 2018-11-09 00:13:30 +01:00
Kevin Jahns
31d6ef6296 cleanup prosemirror example 2018-11-06 15:15:27 +01:00
Kevin Jahns
32b8fac37f added prosemirror binding 2018-11-06 13:44:35 +01:00
Kevin Jahns
e8060de914 13.0.0-69 2018-11-02 01:54:53 +01:00
Kevin Jahns
22b036527c further refine build process to also include lib 2018-11-02 01:54:40 +01:00
Kevin Jahns
feb1e030d7 13.0.0-68 2018-11-02 01:52:24 +01:00
Kevin Jahns
bd271e3952 update publish process 2018-11-02 01:52:20 +01:00
Kevin Jahns
df80938190 13.0.0-67 2018-11-02 00:47:09 +01:00
Kevin Jahns
67bbc0a3fe implemented websocket provider 2018-10-30 00:51:09 +01:00
Kevin Jahns
e1ece6dc66 refactoring: removed default connector and persistence, new code style, proper jsdocs, enabled typechecking 2018-10-29 21:58:21 +01:00
Kevin Jahns
fe038822a3 Merge branch 'ydb-integration' of https://github.com/y-js/yjs into ydb-integration 2018-10-22 12:23:47 +02:00
Kevin Jahns
dece14486c start refactoring 2018-10-22 12:23:35 +02:00
Kevin Jahns
2daffbc2ca implement syncstate 2018-10-17 15:14:47 +02:00
Kevin Jahns
4c01a34d09 integrate ydb client and adapt some demos 2018-10-13 14:38:29 +02:00
Kevin Jahns
3b08267daa merge experimental-connectors 2018-10-08 17:11:18 +02:00
Kevin
b98ebddb69 ydb client 2018-10-08 16:09:50 +02:00
Kevin Jahns
9d5bf50676 13.0.0-66 2018-07-17 18:50:03 +02:00
Kevin Jahns
c0972f8158 reset selection also for local transactions 2018-07-17 18:49:28 +02:00
Kevin Jahns
548125a944 13.0.0-65 2018-07-16 18:38:09 +02:00
Kevin Jahns
a7b124ca6e 13.0.0-64 2018-07-16 18:19:36 +02:00
Kevin Jahns
4022374620 dombinding: always set browser range after change 2018-07-16 18:15:24 +02:00
Kevin Jahns
860e4d7af6 13.0.0-63 2018-06-23 00:30:45 +02:00
Kevin Jahns
6376d69b58 fix undo of map update 2018-06-23 00:29:44 +02:00
Kevin Jahns
5cf6f45f19 13.0.0-62 2018-06-13 00:08:01 +02:00
Kevin Jahns
967903673b fixed undo/redo issues and implemented ability to manually flush the UndoManager 2018-06-13 00:06:38 +02:00
Kevin Jahns
2d897f1844 add function to create lots of rooms 2018-06-07 16:36:42 +02:00
Kevin Jahns
fb2f9bc493 add client-server updateCounter support to sync all persisted rooms 2018-06-04 17:35:39 +02:00
Kevin Jahns
6f9ae0c4fc save current state at the beginning in YIndexedDB 2018-06-03 22:39:22 +02:00
Kevin Jahns
9df20fac8a added YIndexedDB 2018-06-03 21:58:51 +02:00
Kevin Jahns
a1fb1a6258 add persistence decoder 2018-06-02 22:16:12 +02:00
Kevin Jahns
417d0ef3b5 save state in FilePersistence 2018-06-02 13:33:04 +02:00
Kevin Jahns
9be256231b enable gc in demo 2018-05-23 16:15:11 +02:00
Kevin Jahns
c122bdc750 BinaryEncoder writes directly to ArrayBuffer, improves memory consumption 2018-05-23 16:06:38 +02:00
Kevin Jahns
4ef36ab81c fix tests 2018-05-23 14:16:54 +02:00
Kevin Jahns
cccc0e1015 implemented experimental websockets-connector 2018-05-23 14:01:00 +02:00
Kevin Jahns
db5312443e 13.0.0-61 2018-05-18 02:02:44 +02:00
Kevin Jahns
dbda07424b fix DomBinding destroy 2018-05-18 02:01:53 +02:00
Kevin Jahns
684d38d6c8 make compatible with webpack and less sophisticated module bundlers 2018-05-13 13:07:24 +02:00
Kevin Jahns
44fa064eb2 Merge branch 'master' of github.com:y-js/yjs 2018-05-12 16:43:28 +02:00
Kevin Jahns
9b6fffd880 Add rollup dependency - closes #110 2018-05-12 16:43:05 +02:00
Kevin Jahns
e9993b2643 Merge pull request #111 from larskarbo/patch-1
(DOCS) Remove y-array duplicates
2018-05-12 16:41:14 +02:00
Kevin Jahns
762e9e8a3a do not log _start in logItemHelper. Fixes #114 2018-05-12 15:42:31 +02:00
Kevin Jahns
6ddeb788c7 Merge branch 'master' of github.com:y-js/yjs 2018-05-09 16:28:07 +02:00
Kevin Jahns
b9245f323c prefer !== undefined check instead of hasOwnProperty 2018-05-09 16:27:55 +02:00
Kevin Jahns
c0e630b635 prefer parentElement instead of parentNode 2018-05-09 14:42:24 +02:00
Kevin Jahns
e56457a0ef 13.0.0-60 2018-05-08 13:46:27 +02:00
Kevin Jahns
ca13849828 fix domBinding infinite loop 2018-05-08 13:45:51 +02:00
Kevin Jahns
92c2fbd6d3 remove debug dependency 2018-05-07 11:52:37 +02:00
Kevin Jahns
65b8921f05 13.0.0-59 2018-05-07 11:45:39 +02:00
Kevin Jahns
1ace7f4b73 fix parentNode binding in case parent doesn't fire MO 2018-05-07 11:44:22 +02:00
Kevin Jahns
6336064516 13.0.0-58 2018-05-05 15:33:53 +02:00
Kevin Jahns
49d2e42b41 fix missing dom-binding - still investigating 2018-05-05 15:33:16 +02:00
Kevin Jahns
c098e8e745 implement scroll-fixer 2018-05-05 14:47:59 +02:00
Kevin Jahns
38558a7fad 13.0.0-57 2018-05-02 18:42:56 +02:00
Kevin Jahns
bdb3782f8f add option to disable gc (compatible with older versions) 2018-05-02 18:42:18 +02:00
Kevin Jahns
bc32f7348e 13.0.0-56 2018-04-27 18:43:24 +02:00
Kevin Jahns
09a94f053e merge with master 2018-04-27 18:39:34 +02:00
Kevin Jahns
0df0079fa3 Merge branch 'master' of github.com:y-js/yjs 2018-04-27 18:33:51 +02:00
Kevin Jahns
a54d826d6d bugfixes 2018-04-27 18:33:28 +02:00
Kevin Jahns
99f92cb9a0 fix filtering 2018-04-26 16:01:17 +02:00
Kevin Jahns
e788ad1333 fix hook binding 2018-04-26 14:07:12 +02:00
Kevin Jahns
1fe37c565e hooks port to domBinding 2018-04-26 13:26:21 +02:00
Lars Karbo
ed2273e2ed Remove y-array duplicates 2018-04-24 12:59:51 -07:00
Kevin Jahns
94933a704d correctly handle gc with UndoManager and un-merge when syncing 2018-04-23 13:25:40 +02:00
Kevin Jahns
ef6eb08335 fix most gc bugs - test suite running again 2018-04-19 18:28:25 +02:00
Kevin Jahns
d915c8dd13 prelim gc 2018-04-03 13:28:24 +02:00
Kevin Jahns
32207cbca0 implement new mark deleted / gc approach 2018-03-29 16:36:34 +02:00
Kevin Jahns
135c6d31be documentation and fix tests 2018-03-29 11:58:02 +02:00
Kevin Jahns
61149b458a less duplicate code 2018-03-23 05:22:45 +01:00
Kevin Jahns
ba97bfdd9e remove fundraiser campaign 2018-03-23 04:44:26 +01:00
Kevin Jahns
689bca8602 Merge remote-tracking branch 'origin' into v13-doc 2018-03-23 04:40:08 +01:00
Kevin Jahns
6dd43cde17 cleanup docs 2018-03-23 04:39:32 +01:00
Kevin Jahns
026675b438 separate dom binding 2018-03-23 01:55:47 +01:00
Kevin Jahns
941a22b257 13.0.0-55 2018-03-14 18:52:49 -07:00
Kevin Jahns
4aa41b98a9 fix dom filtering bug 2018-03-14 18:51:48 -07:00
Kevin Jahns
acf443aacb reworking bindings 2018-03-12 03:36:37 +01:00
Kevin Jahns
aa8c934833 Add fundraiser campaign 2018-03-06 05:27:03 +01:00
Kevin Jahns
814af5a3d7 fix import locations 2018-03-06 05:22:18 +01:00
Kevin Jahns
bbc207aaa6 restructer and move to esdoc 2018-03-06 03:17:50 +01:00
Kevin Jahns
a9b610479d big documentation update - all public functions and classes are documented now 2018-03-05 03:12:04 +01:00
Kevin Jahns
079de07eff 13.0.0-54 2018-03-01 16:45:25 +01:00
Kevin Jahns
54453e87fa fix consecutive undo,redo,undo,redo.. (abc test) 2018-03-01 16:44:26 +01:00
Kevin Jahns
1b0e3659c3 undo fixes for consecutive undo-redo 2018-03-01 13:50:01 +01:00
Kevin Jahns
dc22a79ac4 properly unregister event when binding is destroyed 2018-02-27 03:52:40 +01:00
Kevin Jahns
384a4b72b0 add quill-cursors example 2018-02-26 17:24:28 +01:00
Kevin Jahns
f35c056bde fix some tests 2018-02-26 03:23:22 +01:00
Kevin Jahns
250050e83b Merge remote-tracking branch 'origin' into y-richtext-rewrite 2018-02-26 02:19:08 +01:00
Kevin Jahns
248d08be30 implement quill binding for y-text 2018-02-26 02:18:39 +01:00
Kevin Jahns
641f426339 13.0.0-53 2018-02-25 02:31:59 +01:00
Kevin Jahns
fcbca65d8f fromBinary is a transaction 2018-02-25 02:31:20 +01:00
Kevin Jahns
5f8ae0dd43 13.0.0-52 2018-02-18 19:20:00 +01:00
Kevin Jahns
de14fe0f3e fix getAttribute vs attributes.value fixes y-js/y-xml#8 2018-02-18 18:58:49 +01:00
Kevin Jahns
5e4b071693 actually use clock in undo-manager 2018-02-15 18:58:43 +01:00
Kevin Jahns
937de2c59f fix fast undo-redo bug 2018-02-15 18:28:53 +01:00
Kevin Jahns
f1f1bff901 preliminary undo-redo fixes 2018-02-15 17:58:14 +01:00
Kevin Jahns
da748a78f4 start rewriting y-richtext 2018-02-15 01:25:08 +01:00
Kevin Jahns
4855b2d590 13.0.0-51 2018-02-07 14:08:43 +01:00
Kevin Jahns
908ce31e2f Merge branch 'master' of github.com:y-js/yjs 2018-02-07 14:08:07 +01:00
Kevin Jahns
e4d4c23f0b bugfix - persist deletes when syncing 2018-02-07 14:07:57 +01:00
Kevin Jahns
fc500a8247 Merge pull request #94 from LukasDrgon/patch-3
Add CDN usage
2018-01-31 20:36:26 -08:00
Kevin Jahns
4b84541d76 13.0.0-50 2018-01-30 20:12:58 -08:00
Kevin Jahns
a3ab42c157 implemnt mutual exclude pattern directly in Persistence.js 2018-01-30 20:11:59 -08:00
Kevin Jahns
bbd3317d62 13.0.0-49 2018-01-30 15:53:33 -08:00
Kevin Jahns
5d3922cb64 fix undo-redo 2018-01-30 15:52:36 -08:00
Kevin Jahns
a81a2cd553 13.0.0-48 2018-01-29 16:41:52 -08:00
Kevin Jahns
c0d24bdba4 lint 2018-01-29 16:41:27 -08:00
Kevin Jahns
40e913e9c5 add toBinary and fromBinary to Y.utils 2018-01-29 16:39:09 -08:00
Kevin Jahns
94f6a0fd9c implement Y.*Binding approach 2018-01-29 11:55:28 -08:00
Kevin Jahns
41a88dbc43 fix examples for Yjs@13 2018-01-25 17:28:33 -07:00
Kevin Jahns
1d4f283955 13.0.0-47 2018-01-18 18:44:56 +01:00
Kevin Jahns
fc3a4c376c implement when-handler 2018-01-18 18:44:20 +01:00
Lukas Drgon
acb0affa33 Add CDN usage 2018-01-17 22:03:49 +01:00
Kevin Jahns
0b510b64a3 persistence updates + make Persistence.init async 2018-01-16 16:13:47 +01:00
Kevin Jahns
c8f0cf5556 13.0.0-46 2018-01-10 00:20:03 +01:00
Kevin Jahns
11a4271fd1 13.0.0-45 2018-01-10 00:18:50 +01:00
Kevin Jahns
c7670915c7 Merge branch 'master' of github.com:y-js/yjs 2018-01-10 00:17:34 +01:00
Kevin Jahns
eb2d596538 implement mutualExclude factory 2018-01-10 00:17:26 +01:00
Kevin Jahns
48e17ea1a7 13.0.0-44 2018-01-10 00:16:33 +01:00
Kevin Jahns
1a22fdd45e persistence improvements 2018-01-10 00:11:25 +01:00
Kevin Jahns
07cf0b3436 export AbstractPersistence 2018-01-08 17:30:30 +01:00
Kevin Jahns
5a68b9f4ad loaded event when loaded from persistence adapter 2018-01-08 02:28:46 +01:00
Kevin Jahns
445dd3e0da fix several y-xml bugs 2018-01-03 03:50:27 +01:00
Kevin Jahns
0ba97d78f8 better relative cursor positions for text editing - decrease number of generated messages for cursor 2017-12-31 16:14:02 +01:00
Kevin Jahns
fc5be5c7cc fix empty string insertion bug 2017-12-31 14:49:20 +01:00
Kevin Jahns
f2debc150c reimplement persistence approach 2017-12-24 03:18:00 +01:00
Kevin Jahns
08f37a86e3 13.0.0-43 2017-12-21 16:06:29 +01:00
Kevin Jahns
f5d17e6236 filter y-xml when domFilter is set 2017-12-21 16:05:50 +01:00
Kevin Jahns
8f3bd7170a 13.0.0-42 2017-12-19 17:39:01 +01:00
Kevin Jahns
5586334549 fix initial content in y-array 2017-12-19 17:37:04 +01:00
Kevin Jahns
24c1e4dcc8 13.0.0-41 2017-12-14 14:30:02 +01:00
Kevin Jahns
d61bbecf4e fix tree walker on YXmlFragment 2017-12-14 14:29:16 +01:00
Kevin Jahns
85492ad2e0 fix drawing example. Add drawing hook for y-xml 2017-12-13 12:49:34 +01:00
Kevin Jahns
02253f9a8d fix log outputs 2017-12-13 10:28:19 +01:00
Kevin Jahns
8105bef1af work on drawing demo 2017-12-06 19:20:52 -08:00
Kevin Jahns
4efa16e2dd 13.0.0-40 2017-12-05 21:50:34 -08:00
Kevin Jahns
ad44f59def implement new dom update algorithm 2017-12-05 21:50:00 -08:00
Kevin Jahns
9c471ea24d 13.0.0-39 2017-12-05 17:06:01 -08:00
Kevin Jahns
d9e76014f5 fix remaining cursor relocation issues 2017-12-05 17:05:12 -08:00
Kevin Jahns
4091b7d004 13.0.0-38 2017-12-05 00:53:25 -08:00
Kevin Jahns
dfc183643d support data-yjs-hook attribute for yjs hooks 2017-12-05 00:52:52 -08:00
Kevin Jahns
cf8698f2b6 13.0.0-37 2017-12-02 01:45:55 -08:00
Kevin Jahns
3595f14da7 fix insert in y-text 2017-12-02 01:45:22 -08:00
Kevin Jahns
c6e671b1d5 13.0.0-36 2017-11-30 18:39:17 -08:00
Kevin Jahns
e4c10fd6b3 handle xmlhook in mutation observer 2017-11-30 18:38:12 -08:00
Kevin Jahns
e70aa09f88 Implement YXml element hooks (based on _yjsHook property) 2017-11-29 17:16:06 -08:00
Kevin Jahns
7808b143da 13.0.0-35 2017-11-28 17:38:31 -08:00
Kevin Jahns
b35092928e fix user selection issues 2017-11-28 17:37:15 -08:00
Kevin Jahns
b7dbcf69d3 13.0.0-34 2017-11-26 23:24:41 -08:00
Kevin Jahns
377df18788 prevent updating cursor position if not necessary 2017-11-26 23:23:29 -08:00
Kevin Jahns
26a323733d 13.0.0-33 2017-11-26 14:42:59 -08:00
Kevin Jahns
d0d1015074 filter remote changes in YXml* 2017-11-26 14:42:06 -08:00
Kevin Jahns
2e3240b379 13.0.0-32 2017-11-14 21:31:11 -08:00
Kevin Jahns
2558652356 fix attribute filter (it used to filter everything) 2017-11-14 21:19:39 -08:00
Kevin Jahns
783cbd63fc 13.0.0-31 2017-11-14 20:44:12 -08:00
Kevin Jahns
41be80e751 fix y-xml server environment 2017-11-14 20:43:30 -08:00
Kevin Jahns
3d6050d8a2 13.0.0-30 2017-11-12 13:37:37 -08:00
Kevin Jahns
3d5ba7b4cc fix the case that a new transaction starts in an event listener (afterTransaction, observe, observeDeep) 2017-11-12 13:37:06 -08:00
Kevin Jahns
415b66607c fixed filtering 2017-11-10 19:04:00 -08:00
Kevin Jahns
05cd1d0575 13.0.0-29 2017-11-10 18:46:10 -08:00
Kevin Jahns
4edc22bedb remove prematurely commited dom-filter update 2017-11-10 18:45:41 -08:00
Kevin Jahns
16f84c67d5 13.0.0-28 2017-11-10 18:41:39 -08:00
Kevin Jahns
290d3c8ffe support undefined as an attribute value 2017-11-10 18:41:10 -08:00
Kevin Jahns
c51e8b46c2 13.0.0-27 2017-11-10 12:55:05 -08:00
Kevin Jahns
0cda1630d2 fix path bugs 2017-11-10 12:54:33 -08:00
Kevin Jahns
d232b883e9 13.0.0-26 2017-11-09 17:32:45 -08:00
Kevin Jahns
3a0e65403f fix undo scope 2017-11-09 17:31:58 -08:00
Kevin Jahns
224fff93ba 13.0.0-25 2017-11-08 17:31:50 -08:00
Kevin Jahns
4f55e8c655 fix event.path by using event.currentTarget 2017-11-08 17:31:12 -08:00
Kevin Jahns
a08624c04e implemented tree-walker 2017-11-08 13:40:36 -08:00
Kevin Jahns
9b00929172 13.0.0-24 2017-11-08 00:02:24 -08:00
Kevin Jahns
b94267e14a prevent mangling of Types and other useful data 2017-11-08 00:01:18 -08:00
Kevin Jahns
e696304845 13.0.0-23 2017-11-07 22:52:33 -08:00
Kevin Jahns
d503c9d640 lint 2017-11-07 22:51:46 -08:00
Kevin Jahns
e5f289506f observeDeep receives array of events 2017-11-07 22:44:43 -08:00
Kevin Jahns
c453593ee7 y-xml: hand over fake document if necessary 2017-11-07 21:06:29 -08:00
Kevin Jahns
5ed1818de5 fix selection RootID name 2017-11-07 20:47:00 -08:00
Kevin Jahns
0310500c4e add YEvent 2017-11-07 20:34:44 -08:00
Kevin Jahns
b7defc32e8 fix selecting multiple paragraphs 2017-11-07 20:28:31 -08:00
Kevin Jahns
dbdd49af23 fix relativePosition if startof is a root type 2017-11-07 20:10:01 -08:00
Kevin Jahns
b7c05ba133 fix YMap.keys() 2017-11-07 19:56:03 -08:00
Kevin Jahns
9298903bdb filter out attribute events for YXmlFragment 2017-11-07 19:31:57 -08:00
Kevin Jahns
d59e30b239 implement generic YEvent with path property 2017-11-07 19:18:41 -08:00
Kevin Jahns
d29b83a457 improve backwards-compatibility 2017-11-07 18:31:04 -08:00
Kevin Jahns
0208d83f91 implemented undo 🙌 2017-10-30 11:47:56 +01:00
Kevin Jahns
c545118637 fixed selection handler befor/after transactions 2017-10-28 23:02:48 +02:00
Kevin Jahns
c619aa33d9 fixed inserting large xml portions 2017-10-27 22:28:42 +02:00
Kevin Jahns
1dea8f394f unbind yxml immediately when deleted 2017-10-26 21:02:25 +02:00
Kevin Jahns
5cf8d20cf6 delete child if parent is deleted 2017-10-26 20:53:27 +02:00
Kevin Jahns
74f9ceab01 fixed dom filtering tests 2017-10-26 20:24:09 +02:00
Kevin Jahns
ca81cdf3be fixed xml tests 2017-10-26 19:50:43 +02:00
Kevin Jahns
96c6aa2751 fixed ds syncing bug 2017-10-26 19:12:33 +02:00
Kevin Jahns
e6b5e258fb several DS fixes (logging works now) 2017-10-26 16:22:35 +02:00
Kevin Jahns
e8170a09a7 fixed all insert tests 2017-10-26 14:40:21 +02:00
Kevin Jahns
9d1ad8cb28 fix item-splitting 2017-10-24 16:41:19 +02:00
Kevin Jahns
d859fd68fe fixed several random tests 2017-10-23 22:43:41 +02:00
Kevin Jahns
2b7d2ed1e6 implemented logTable method on data stores 2017-10-22 23:50:49 +02:00
Kevin Jahns
142a5ada60 fix some tests, implement event classes for types, and re-implement logging 2017-10-22 19:13:12 +02:00
Kevin Jahns
c92f987496 fix some tests, implement event classes for types, and re-implement logging 2017-10-22 19:12:50 +02:00
Kevin Jahns
755c9eb16e implemented xml type for new event system 2017-10-19 17:36:28 +02:00
Kevin Jahns
1311c7a0d8 fix first y-array test 2017-10-16 04:53:12 +02:00
Kevin Jahns
4eec8ecdd3 fix encoding and rb tree tests 2017-10-15 12:21:14 +02:00
Kevin Jahns
0e426f8928 fix compiling issues 2017-10-14 23:03:24 +02:00
Kevin Jahns
82015d5a37 refactor the whole damn thing 2017-10-11 03:41:54 +02:00
Kevin Jahns
d9ee67d2f3 13.0.0-22 2017-10-07 00:42:06 +02:00
Kevin Jahns
791f6c12f0 add indexeddb example 2017-10-07 00:40:34 +02:00
Kevin Jahns
23d019c244 add writeObjectToYMap and writeArrayToYArray helper utilities 2017-10-07 00:39:26 +02:00
Kevin Jahns
c8ca80d15f 13.0.0-21 2017-10-02 15:52:11 +02:00
Kevin Jahns
be282c8338 fix lint 2017-10-02 15:50:56 +02:00
Kevin Jahns
829a094c6d check for responsiveness when maxBufferSize is set 2017-10-02 15:45:23 +02:00
Kevin Jahns
725273167e 13.0.0-20 2017-09-29 22:34:18 +02:00
Kevin Jahns
581264c5e3 implement relative position helper 2017-09-29 22:33:28 +02:00
Kevin Jahns
be537c9f8c 13.0.0-19 2017-09-26 21:53:01 +02:00
Kevin Jahns
4028eee39d implemented chunked broadcast of updates 2017-09-26 21:52:07 +02:00
Kevin Jahns
0e3e561ec7 13.0.0-18 2017-09-20 11:34:03 +02:00
Kevin Jahns
7df46cb731 Merge branch 'master' of github.com:y-js/yjs 2017-09-20 11:30:24 +02:00
Kevin Jahns
40fb16ef32 catch y-* related errors 2017-09-20 11:29:13 +02:00
Kevin Jahns
ada5d36cd5 add more y-xml tests 2017-09-19 03:16:48 +02:00
Kevin Jahns
f537a43e29 implement tests for dom filter 2017-09-18 22:14:45 +02:00
Kevin Jahns
3a305fb228 13.0.0-17 2017-09-11 17:38:21 +02:00
Kevin Jahns
1afdab376d fix linting 2017-09-11 17:37:39 +02:00
Kevin Jahns
526c862071 added test case for moving nodes 2017-09-11 17:35:20 +02:00
Kevin Jahns
fdbb558ce2 persistence db fixes 2017-09-11 16:02:19 +02:00
Kevin Jahns
76ad58bb59 fix example dist script 2017-09-07 23:02:19 +02:00
Kevin Jahns
c88a813bb0 fix tests by removing y-memory include 2017-09-06 20:52:52 +02:00
Kevin Jahns
ccf6d86c98 removed generators 2017-09-06 20:10:38 +02:00
Kevin Jahns
6b5c02f1ce 13.0.0-16 2017-08-26 01:11:31 +02:00
Kevin Jahns
2be6e935a4 fix lint in xml tests 2017-08-26 01:10:50 +02:00
Kevin Jahns
0ddf3bf742 Y.Xml renamed to Y.XmlElement 2017-08-25 20:35:17 +02:00
Kevin Jahns
5f29724578 merge textarea example 2017-08-24 14:46:16 +02:00
Kevin Jahns
ab6cde07e6 Implemented Xml Struct 2017-08-24 14:44:40 +02:00
Kevin Jahns
0455eaa8ad 13.0.0-15 2017-08-14 15:53:54 +02:00
Kevin Jahns
9ed7e15d0f 13.0.0-14 2017-08-14 15:49:15 +02:00
Kevin Jahns
6e633d0bd9 lint 2017-08-14 15:41:37 +02:00
Kevin Jahns
e16195cb54 implement timeout for creating Yjs instance 2017-08-14 15:39:17 +02:00
Kevin Jahns
86c46cf0ec 13.0.0-13 2017-08-13 01:04:37 +02:00
Kevin Jahns
8770c8e934 Implement persistence layer 2017-08-13 01:03:54 +02:00
Kevin Jahns
7e12ea2db5 move array tests and map tests to yjs 2017-08-09 02:21:17 +02:00
Kevin Jahns
3ca260e0da 13.0.0-12 2017-08-04 18:07:44 +02:00
Kevin Jahns
edb5e4f719 send sync step 1 after sync step 2 is processed (for slaves) 2017-08-04 18:06:36 +02:00
Kevin Jahns
be3b8b65ce 13.0.0-11 2017-08-04 16:30:58 +02:00
Kevin Jahns
d093ef56c8 userJoined accepts auth parameter. Sync with all users at once, instead of one at a time 2017-08-04 16:27:07 +02:00
Kevin Jahns
90b2a895b8 13.0.0-10 2017-08-03 00:25:13 +02:00
Kevin Jahns
4f57c91b82 fix syncing protocol - compute messages after auth 2017-08-03 00:24:01 +02:00
Kevin Jahns
3e1d89253f fix unhandled message bug in connector 2017-08-01 17:49:37 +02:00
Kevin Jahns
03e1a3fc12 13.0.0-9 2017-08-01 16:21:38 +02:00
Kevin Jahns
5c33f41c30 fix linting 2017-08-01 16:19:25 +02:00
Kevin Jahns
65e8c29b33 remove all async-functions - making it compatible with node 6 2017-08-01 16:15:36 +02:00
Kevin Jahns
fed77d532f 13.0.0-8 2017-07-31 16:05:30 +02:00
Kevin Jahns
d129184f7b fix linting 2017-07-31 15:43:04 +02:00
Kevin Jahns
a05bb1d4f9 merge bugfix-unable-to-deliver-message branch 2017-07-31 15:40:25 +02:00
Kevin Jahns
65af4963e6 merge bugfix-multiple-clients-sync branch 2017-07-31 15:35:27 +02:00
Kevin Jahns
4dce0816a6 fix preferUntransformed sync 2017-07-31 14:41:40 +02:00
Kevin Jahns
5384bf4faf remove unneccesarry whenTransactionsFinished command 2017-07-31 14:01:34 +02:00
Kevin Jahns
454ac9ba16 remove ds.length == 0 condition for preferUntransformed 2017-07-31 03:19:47 +02:00
Kevin Jahns
e2ec53be65 implemented three-way sync for master-slave apps 2017-07-31 02:06:07 +02:00
Kevin Jahns
aa6edcfd9b add warning for message type when ArrayBuffer is expected 2017-07-31 01:13:52 +02:00
Kevin Jahns
f31ec9a8b8 fixed varUint encoding issue 2017-07-30 22:16:59 +02:00
Kevin Jahns
003fa735a0 enable y-map tests 2017-07-27 15:15:20 +02:00
Kevin Jahns
574f0c3269 fix logging message type 2017-07-27 14:49:36 +02:00
Kevin Jahns
eb4fb3a225 binary encoding bugfixes & export BinaryEncoder + BinaryDecoder 2017-07-24 15:37:04 +02:00
Kevin Jahns
c97130abc4 implement generateUserId for node & clients that dont support crypto 2017-07-22 18:37:48 +02:00
Kevin Jahns
a19cfa1465 redesigned connector protocol - enabled binary compression 2017-07-22 18:07:56 +02:00
Kevin Jahns
bb45abbb70 13.0.0-7 2017-07-22 01:16:50 +02:00
Kevin Jahns
67b47fd868 bugfix - sync step 2 also authenticates) 2017-07-22 01:15:13 +02:00
Kevin Jahns
2c18b9ffad 13.0.0-6 2017-07-21 23:56:13 +02:00
Kevin Jahns
a6b7d76544 bugfix: unable to deliver message. fixes receiving message before authentication 2017-07-21 23:55:11 +02:00
Kevin Jahns
442ea7ec70 13.0.0-5 2017-07-19 21:22:37 +02:00
Kevin Jahns
747da52c0b fix two clients syncing at the time 2017-07-19 21:19:41 +02:00
Kevin Jahns
6c37bd4463 Merge remote-tracking branch 'origin/master' into v13 2017-07-13 20:03:29 +02:00
Kevin Jahns
dd6c196135 link to the IPFS connector 2017-07-13 19:51:29 +02:00
Kevin Jahns
252bec0ad2 implemented binary encoding for all basic structs 2017-07-13 17:42:21 +02:00
Kevin Jahns
6c8876d282 remove option forwardToSyncing clients as it is no longer necessary - it was previously only used by y-webrtc 2017-07-13 00:48:14 +02:00
Kevin Jahns
3c317828d1 Use integer as userId instead of String 2017-07-13 00:37:35 +02:00
Kevin Jahns
cd3f4a72d6 13.0.0-4 2017-07-06 15:17:23 +02:00
Kevin Jahns
2c852c85c6 add node build 2017-07-06 15:16:13 +02:00
Kevin Jahns
434ec84837 13.0.0-3 2017-07-06 03:29:09 +02:00
Kevin Jahns
2b618cd83c change to correct main file 2017-07-06 03:28:06 +02:00
Kevin Jahns
f4327529b9 13.0.0-2 2017-07-05 18:41:26 +02:00
Kevin Jahns
67189f4d44 dont lint in postversion 2017-07-05 18:40:41 +02:00
Kevin Jahns
6225fb4dfd fix linting of examples 2017-07-05 18:33:16 +02:00
Kevin Jahns
a7550fe5d3 13.0.0-1 2017-07-05 18:12:35 +02:00
Kevin Jahns
9d9c84f40e fit y-memory in helper.js 2017-07-05 18:10:24 +02:00
Kevin Jahns
ae91902de3 Yjs throws "error" event in unexpected cases. fixes #72 2017-07-05 17:58:19 +02:00
Kevin Jahns
033d24eee7 use y-memory@v8 directory structure 2017-07-05 17:44:17 +02:00
Kevin Jahns
8abef69aa7 implemented named event handler 2017-07-05 17:01:21 +02:00
Kevin Jahns
7e4dedab38 always use generateUniqueUserId. fixes #74 2017-07-05 11:40:19 +02:00
Kevin Jahns
85e488bbe6 Throw proper error stack when observer function thrown an error - implements #75 2017-07-05 11:37:22 +02:00
Kevin Jahns
a6a321da10 fix textarea example to fit new directory structure 2017-07-05 11:34:28 +02:00
Kevin Jahns
008764ccdc remove dist submodule 2017-07-05 11:26:20 +02:00
Kevin Jahns
de5f4abe32 filter deleted ops only if gc is disabled 2017-07-04 04:59:07 -07:00
Kevin Jahns
382d06f6d4 reworked getOperations (decrease size of sent operations, fixe some gc issues). garbageCollectOperation now sets origin to the direct left operation, if possible 2017-07-03 23:19:11 -07:00
Kevin Jahns
66de422749 fix issues with new master-slave tests 2017-06-30 15:18:07 -07:00
Kevin Jahns
bbf5e39408 implemented client-server model (untested) 2017-06-30 14:07:14 -07:00
94 changed files with 12863 additions and 8535 deletions

View File

@@ -1,12 +0,0 @@
{
"presets": [
["latest", {
"es2015": {
"modules": false
}
}]
],
"plugins": [
"external-helpers"
]
}

View File

@@ -1,12 +0,0 @@
[ignore]
.*/node_modules/.*
.*/dist/.*
.*/build/.*
[include]
./src/
[libs]
./declarations/
[options]

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

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

15
.gitignore vendored
View File

@@ -1,15 +1,4 @@
node_modules
bower_components
build
build_test
.directory
.codio
.settings
.jshintignore
.jshintrc
.validate.json
/y.js
/y.js.map
/y-*
dist
.vscode
jsconfig.json
docs

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule "dist"]
path = dist
url = https://github.com/y-js/yjs.git
branch = dist

50
.jsdoc.json Normal file
View File

@@ -0,0 +1,50 @@
{
"sourceType": "module",
"tags": {
"allowUnknownTags": true,
"dictionaries": ["jsdoc"]
},
"source": {
"include": ["./src"],
"includePattern": ".js$"
},
"plugins": [
"plugins/markdown"
],
"templates": {
"referenceTitle": "Yjs",
"disableSort": false,
"useCollapsibles": true,
"collapse": true,
"resources": {
"yjs.dev": "Yjs website"
},
"logo": {
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
"width": "162px",
"height": "162px",
"link": "/"
},
"tabNames": {
"api": "API",
"tutorials": "Examples"
},
"footerText": "Shared Editing",
"css": [
"./style.css"
],
"default": {
"staticFiles": {
"include": ["examples/"]
}
}
},
"opts": {
"destination": "./docs/",
"encoding": "utf8",
"private": false,
"recurse": true,
"template": "./node_modules/tui-jsdoc-template",
"tutorials": "./examples"
}
}

4
.markdownlint.json Normal file
View File

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

1101
README.md

File diff suppressed because it is too large Load Diff

1
dist

Submodule dist deleted from 42aa7ec5c9

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style type="text/css" media="screen">
#aceContainer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.inserted {
position:absolute;
z-index:20;
background-color: #FFC107;
}
.deleted {
position:absolute;
z-index:20;
background-color: #FFC107;
}
</style>
</head>
<body>
<div id="aceContainer"></div>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/ace-builds/src/ace.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,24 +0,0 @@
/* global Y, ace */
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'ace-example'
},
sourceDir: '/bower_components',
share: {
ace: 'Text' // y.share.textarea is of type Y.Text
}
}).then(function (y) {
window.yAce = y
// bind the textarea to a shared text element
var editor = ace.edit('aceContainer')
editor.setTheme('ace/theme/chrome')
editor.getSession().setMode('ace/mode/javascript')
y.share.ace.bindAce(editor)
})

View File

@@ -1,29 +0,0 @@
{
"name": "yjs-examples",
"version": "0.0.0",
"homepage": "y-js.org",
"authors": [
"Kevin Jahns <kevin.jahns@rwth-aachen.de>"
],
"description": "Examples for Yjs",
"license": "MIT",
"ignore": [],
"dependencies": {
"yjs": "latest",
"y-array": "latest",
"y-map": "latest",
"y-memory": "latest",
"y-richtext": "latest",
"y-webrtc": "latest",
"y-websockets-client": "latest",
"y-text": "latest",
"y-indexeddb": "latest",
"y-xml": "latest",
"quill": "^1.0.0-rc.2",
"ace": "~1.2.3",
"ace-builds": "~1.2.3",
"jquery": "~2.2.2",
"d3": "^3.5.16",
"codemirror": "^5.25.0"
}
}

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<style>
#chat p span {
color: blue;
}
</style>
<div id="chat"></div>
<form id="chatform">
<input name="username" type="text" style="width:15%;">
<input name="message" type="text" style="width:60%;">
<input type="submit" value="Send">
</form>
<script src="../bower_components/yjs/y.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,75 +0,0 @@
/* @flow */
/* global Y */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'chat-example'
},
sourceDir: '/bower_components',
share: {
chat: 'Array'
}
}).then(function (y) {
window.yChat = y
// This functions inserts a message at the specified position in the DOM
function appendMessage(message, position) {
var p = document.createElement('p')
var uname = document.createElement('span')
uname.appendChild(document.createTextNode(message.username + ": "))
p.appendChild(uname)
p.appendChild(document.createTextNode(message.message))
document.querySelector('#chat').insertBefore(p, chat.children[position] || null)
}
// This function makes sure that only 7 messages exist in the chat history.
// The rest is deleted
function cleanupChat () {
var len
while ((len = y.share.chat.length) > 7) {
y.share.chat.delete(0)
}
}
// Insert the initial content
y.share.chat.toArray().forEach(appendMessage)
cleanupChat()
// whenever content changes, make sure to reflect the changes in the DOM
y.share.chat.observe(function (event) {
if (event.type === 'insert') {
for (var i = 0; i < event.length; i++) {
appendMessage(event.values[i], event.index + i)
}
} else if (event.type === 'delete') {
for (var i = 0; i < event.length; i++) {
chat.children[event.index].remove()
}
}
// concurrent insertions may result in a history > 7, so cleanup here
cleanupChat()
})
document.querySelector('#chatform').onsubmit = function (event) {
// the form is submitted
var message = {
username: this.querySelector("[name=username]").value,
message: this.querySelector("[name=message]").value
}
if (message.username.length > 0 && message.message.length > 0) {
if (y.share.chat.length > 6) {
// If we are goint to insert the 8th element, make sure to delete first.
y.share.chat.delete(0)
}
// Here we insert a message in the shared chat type.
// This will call the observe function (see line 40)
// and reflect the change in the DOM
y.share.chat.push([message])
this.querySelector("[name=message]").value = ""
}
// Do not send this form!
event.preventDefault()
return false
}
})

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div id="codeMirrorContainer"></div>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/codemirror/lib/codemirror.js"></script>
<script src="../bower_components/codemirror/mode/javascript/javascript.js"></script>
<link rel="stylesheet" href="../bower_components/codemirror/lib/codemirror.css">
<style>
.CodeMirror {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
</style>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,24 +0,0 @@
/* global Y, CodeMirror */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'codemirror-example'
},
sourceDir: '/bower_components',
share: {
codemirror: 'Text' // y.share.codemirror is of type Y.Text
}
}).then(function (y) {
window.yCodeMirror = y
var editor = CodeMirror(document.querySelector('#codeMirrorContainer'), {
mode: 'javascript',
lineNumbers: true
})
y.share.codemirror.bindCodeMirror(editor)
})

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<style>
path {
fill: none;
stroke: blue;
stroke-width: 1px;
stroke-linejoin: round;
stroke-linecap: round;
}
</style>
<button type="button" id="clearDrawingCanvas">Clear Drawing</button>
<svg id="drawingCanvas" viewbox="0 0 100 100" width="100%"></svg>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/d3/d3.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,84 +0,0 @@
/* globals Y, d3 */
'strict mode'
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'drawing-example'
// url: 'localhost:1234'
},
sourceDir: '/bower_components',
share: {
drawing: 'Array'
}
}).then(function (y) {
window.yDrawing = y
var drawing = y.share.drawing
var renderPath = d3.svg.line()
.x(function (d) { return d[0] })
.y(function (d) { return d[1] })
.interpolate('basis')
var svg = d3.select('#drawingCanvas')
.call(d3.behavior.drag()
.on('dragstart', dragstart)
.on('drag', drag)
.on('dragend', dragend))
// create line from a shared array object and update the line when the array changes
function drawLine (yarray) {
var line = svg.append('path').datum(yarray.toArray())
line.attr('d', renderPath)
yarray.observe(function (event) {
// we only implement insert events that are appended to the end of the array
event.values.forEach(function (value) {
line.datum().push(value)
})
line.attr('d', renderPath)
})
}
// call drawLine every time an array is appended
y.share.drawing.observe(function (event) {
if (event.type === 'insert') {
event.values.forEach(drawLine)
} else {
// just remove all elements (thats what we do anyway)
svg.selectAll('path').remove()
}
})
// draw all existing content
for (var i = 0; i < drawing.length; i++) {
drawLine(drawing.get(i))
}
// clear canvas on request
document.querySelector('#clearDrawingCanvas').onclick = function () {
drawing.delete(0, drawing.length)
}
var sharedLine = null
function dragstart () {
drawing.insert(drawing.length, [Y.Array])
sharedLine = drawing.get(drawing.length - 1)
}
// After one dragged event is recognized, we ignore them for 33ms.
var ignoreDrag = null
function drag () {
if (sharedLine != null && ignoreDrag == null) {
ignoreDrag = window.setTimeout(function () {
ignoreDrag = null
}, 33)
sharedLine.push([d3.mouse(this)])
}
}
function dragend () {
sharedLine = null
window.clearTimeout(ignoreDrag)
ignoreDrag = null
}
})

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
.draggable {
cursor: move;
}
</style>
</head>
<body>
<svg id="puzzle-example" width="100%" viewBox="0 0 800 800">
<g>
<path d="M 311.76636,154.23389 C 312.14136,171.85693 318.14087,184.97998 336.13843,184.23047 C 354.13647,183.48047 351.88647,180.48096 354.88599,178.98096 C 357.8855,177.48096 368.38452,170.35693 380.00806,169.98193 C 424.61841,168.54297 419.78296,223.6001 382.25757,223.6001 C 377.75806,223.6001 363.51001,219.10107 356.38599,211.97656 C 349.26196,204.85254 310.64185,207.10254 314.76636,236.34863 C 316.34888,247.5708 324.08374,267.90723 324.84595,286.23486 C 325.29321,296.99414 323.17603,307.00635 321.58911,315.6377 C 360.11353,305.4585 367.73462,304.30518 404.00513,312.83936 C 410.37915,314.33887 436.62573,310.21436 421.25269,290.3418 C 405.87964,270.46924 406.25464,248.34717 417.12817,240.84814 C 428.00171,233.34912 446.74976,228.84961 457.99829,234.09912 C 469.24683,239.34814 484.61987,255.84619 475.24585,271.59424 C 465.87231,287.34229 452.74878,290.7168 456.49829,303.84033 C 460.2478,316.96387 479.74536,320.33838 500.74292,321.83789 C 509.70142,322.47803 527.97192,323.28467 542.10864,320.12939 C 549.91821,318.38672 556.92212,315.89502 562.46753,313.56396 C 561.40796,277.80664 560.84888,245.71729 560.3606,241.97314 C 558.85278,230.41455 542.49536,217.28564 525.86499,223.2251 C 520.61548,225.1001 519.86548,231.84912 505.24243,232.59912 C 444.92798,235.69238 462.06958,143.26709 525.86499,180.48096 C 539.52759,188.45068 575.19409,190.7583 570.10913,156.85889 C 567.85962,141.86035 553.98608,102.86523 553.98608,102.86523 C 553.98608,102.86523 477.23755,111.82227 451.99878,91.991699 C 441.50024,83.74292 444.87476,69.494629 449.37427,61.245605 C 453.87378,52.996582 465.12231,46.622559 464.74731,36.123779 C 463.02563,-12.086426 392.96704,-10.902832 396.5061,36.873535 C 397.25562,46.997314 406.62964,52.621582 410.75415,60.495605 C 420.00757,78.161377 405.50024,96.073486 384.50757,99.490723 C 377.36206,100.65381 349.17505,102.65332 320.39429,102.23486 C 319.677,102.22461 318.95923,102.21143 318.24194,102.19775 C 315.08423,120.9751 311.55688,144.39697 311.76636,154.23389 z " style="fill:#f2c569;stroke:#000000" id="path2502"/>
<path d="M 500.74292,321.83789 C 479.74536,320.33838 460.2478,316.96387 456.49829,303.84033 C 452.74878,290.7168 465.87231,287.34229 475.24585,271.59424 C 484.61987,255.84619 469.24683,239.34814 457.99829,234.09912 C 446.74976,228.84961 428.00171,233.34912 417.12817,240.84814 C 406.25464,248.34717 405.87964,270.46924 421.25269,290.3418 C 436.62573,310.21436 410.37915,314.33887 404.00513,312.83936 C 367.73462,304.30518 360.11353,305.4585 321.58911,315.6377 C 320.56372,321.21484 319.75854,326.2207 320.01538,330.46191 C 320.76538,342.83545 329.3894,385.95508 327.8894,392.7041 C 326.3894,399.45312 313.64136,418.20117 297.89331,407.32715 C 282.14526,396.45361 276.52075,393.4541 265.27222,394.5791 C 254.02368,395.70361 239.77563,402.07812 239.77563,419.32568 C 239.77563,436.57373 250.27417,449.69727 268.64673,447.82227 C 287.36353,445.9126 317.92163,423.11035 325.63989,452.69678 C 330.1394,469.94434 330.51392,487.19238 330.1394,498.44092 C 329.95825,503.87646 326.09985,518.06592 322.16089,531.28125 C 353.2854,532.73682 386.47095,531.26611 394.2561,529.93701 C 430.30933,523.78174 429.31909,496.09766 412.62866,477.44385 C 406.25464,470.31934 401.75513,455.32129 405.87964,444.82275 C 414.07056,423.97314 458.8064,422.17773 473.37134,438.82324 C 483.86987,450.82178 475.99585,477.44385 468.49683,482.69287 C 453.52222,493.17529 457.22485,516.83008 473.37134,528.06201 C 504.79126,549.91943 572.35913,535.56152 572.35913,535.56152 C 572.35913,535.56152 567.85962,498.06592 567.48462,471.81934 C 567.10962,445.57275 589.60669,450.07227 593.3562,450.07227 C 597.10571,450.07227 604.22974,455.32129 609.47925,459.4458 C 614.72876,463.57031 618.85327,469.94434 630.85181,470.69434 C 677.43726,473.60596 674.58813,420.7373 631.97632,413.32666 C 623.35229,411.82666 614.72876,416.32617 603.10522,424.57519 C 591.48169,432.82422 577.23315,425.32519 570.10913,417.45117 C 566.07788,412.99561 563.8479,360.16406 562.46753,313.56396 C 556.92212,315.89502 549.91821,318.38672 542.10864,320.12939 C 527.97192,323.28467 509.70142,322.47803 500.74292,321.83789 z " style="fill:#f3f3d6;stroke:#000000" id="path2504"/>
<path d="M 240.52563,141.86035 C 257.60327,159.6499 243.94507,188.68799 214.65356,190.22949 C 185.09448,191.78516 164.66675,157.17822 190.28589,136.61621 C 200.49585,128.42139 198.05786,114.12158 179.78296,106.98975 C 154.4187,97.091553 90.54419,107.73975 90.54419,107.73975 C 90.54419,107.73975 100.88794,135.11328 101.41772,168.48242 C 101.79272,192.104 68.796875,189.47949 63.172607,186.85498 C 57.54834,184.23047 45.924805,173.73145 37.675781,173.73145 C -14.411865,173.73145 -10.013184,245.84375 39.925537,232.22412 C 48.174316,229.97461 56.42334,220.97559 68.796875,222.47559 C 81.17041,223.9751 87.544434,232.59912 87.544434,246.09766 C 87.544434,252.51709 87.0354,281.24268 86.340576,312.87012 C 119.15894,313.67676 160.60962,314.46582 170.03442,313.58887 C 186.15698,312.08936 195.90601,301.59033 188.40698,293.3418 C 180.90796,285.09277 156.16089,256.59619 179.03296,239.34814 C 201.90503,222.10059 235.65112,231.84912 239.77563,247.22217 C 243.90015,262.59521 240.52563,273.46924 234.90112,279.09326 C 229.27661,284.71777 210.52905,298.96582 221.40259,308.71484 C 232.27661,318.46338 263.77222,330.83691 302.39282,320.71338 C 309.58862,318.82715 315.92114,317.13525 321.58911,315.6377 C 323.17603,307.00635 325.29321,296.99414 324.84595,286.23486 C 324.08374,267.90723 316.34888,247.5708 314.76636,236.34863 C 310.64185,207.10254 349.26196,204.85254 356.38599,211.97656 C 363.51001,219.10107 377.75806,223.6001 382.25757,223.6001 C 419.78296,223.6001 424.61841,168.54297 380.00806,169.98193 C 368.38452,170.35693 357.8855,177.48096 354.88599,178.98096 C 351.88647,180.48096 354.13647,183.48047 336.13843,184.23047 C 318.14087,184.97998 312.14136,171.85693 311.76636,154.23389 C 311.55688,144.39697 315.08423,120.9751 318.24194,102.19775 C 290.37524,101.67725 262.46069,98.968262 254.39868,97.991211 C 233.38013,95.443359 217.17456,117.53662 240.52563,141.86035 z " style="fill:#bebcdb;stroke:#000000" id="path2506"/>
<path d="M 325.63989,452.69678 C 317.92163,423.11035 287.36353,445.9126 268.64673,447.82227 C 250.27417,449.69727 239.77563,436.57373 239.77563,419.32568 C 239.77563,402.07812 254.02368,395.70361 265.27222,394.5791 C 276.52075,393.4541 282.14526,396.45361 297.89331,407.32715 C 313.64136,418.20117 326.3894,399.45313 327.8894,392.7041 C 329.3894,385.95508 320.76538,342.83545 320.01538,330.46191 C 319.75855,326.2207 320.56372,321.21484 321.58911,315.6377 C 315.92114,317.13525 309.58862,318.82715 302.39282,320.71338 C 263.77222,330.83691 232.27661,318.46338 221.40259,308.71484 C 210.52905,298.96582 229.27661,284.71777 234.90112,279.09326 C 240.52563,273.46924 243.90015,262.59521 239.77563,247.22217 C 235.65112,231.84912 201.90503,222.10059 179.03296,239.34814 C 156.16089,256.59619 180.90796,285.09277 188.40698,293.3418 C 195.90601,301.59033 186.15698,312.08936 170.03442,313.58887 C 160.60962,314.46582 119.15894,313.67676 86.340576,312.87012 C 85.573975,347.74561 84.581299,386.15088 83.794922,402.07812 C 82.295166,432.44922 109.29175,422.32568 115.66577,420.82568 C 122.04028,419.32568 126.16479,409.57715 143.03735,408.45215 C 185.9231,405.59326 186.09985,466.69629 144.16235,467.69482 C 128.41431,468.06982 113.79126,451.19678 108.16675,447.44727 C 102.54272,443.69775 87.919433,442.94775 83.794922,457.9458 C 82.01709,464.41113 78.118652,481.65137 78.098144,496.18994 C 78.071045,515.38037 82.295166,531.81201 82.295166,531.81201 C 82.295166,531.81201 105.54224,526.5625 149.41187,526.5625 C 193.28149,526.5625 199.65552,547.93506 194.78101,558.80859 C 189.90649,569.68213 181.28296,568.93213 179.40796,583.18066 C 172.7063,634.11133 253.34106,631.08203 249.14917,584.68018 C 247.96948,571.62354 237.16528,571.66699 232.27661,557.68359 C 222.17944,528.80273 244.64966,523.56299 257.39819,524.68799 C 263.59351,525.23437 290.95679,529.73389 320.75757,531.21582 C 321.22437,531.23877 321.69312,531.25928 322.16089,531.28125 C 326.09985,518.06592 329.95825,503.87646 330.1394,498.44092 C 330.51392,487.19238 330.1394,469.94434 325.63989,452.69678 z " style="fill:#d3ea9d;stroke:#000000" id="path2508"/>
</g>
</svg>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/d3/d3.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,69 +0,0 @@
/* @flow */
/* global Y, d3 */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'Puzzle-example'
},
sourceDir: '/bower_components',
share: {
piece1: 'Map',
piece2: 'Map',
piece3: 'Map',
piece4: 'Map'
}
}).then(function (y) {
window.yJigsaw = y
var origin // mouse start position - translation of piece
var drag = d3.behavior.drag()
.on('dragstart', function (params) {
// get the translation of the element
var translation = d3.select(this).attr('transform').slice(10,-1).split(',').map(Number)
// mouse coordinates
var mouse = d3.mouse(this.parentNode)
origin = {
x: mouse[0] - translation[0],
y: mouse[1] - translation[1]
}
})
.on("drag", function(){
var mouse = d3.mouse(this.parentNode)
var x = mouse[0] - origin.x // =^= mouse - mouse at dragstart + translation at dragstart
var y = mouse[1] - origin.y
d3.select(this).attr("transform", "translate(" + x + "," + y + ")")
})
.on('dragend', function (piece, i) {
// save the current translation of the puzzle piece
var mouse = d3.mouse(this.parentNode)
var x = mouse[0] - origin.x
var y = mouse[1] - origin.y
piece.set('translation', {x: x, y: y})
})
var data = [y.share.piece1, y.share.piece2, y.share.piece3, y.share.piece4]
var pieces = d3.select(document.querySelector("#puzzle-example")).selectAll("path").data(data)
pieces
.classed('draggable', true)
.attr("transform", function (piece) {
var translation = piece.get('translation') || {x: 0, y: 0}
return "translate(" + translation.x + "," + translation.y + ")"
}).call(drag)
data.forEach(function(piece){
piece.observe(function () {
// whenever a property of a piece changes, update the translation of the pieces
pieces
.transition()
.attr("transform", function (piece) {
var translation = piece.get('translation') || {x: 0, y: 0}
return "translate(" + translation.x + "," + translation.y + ")"
})
})
})
})

View File

@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div id="monacoContainer"></div>
<style>
#monacoContainer {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
</style>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/y-array/y-array.js"></script>
<script src="../bower_components/y-text/y-text.js"></script>
<script src="../bower_components/y-websockets-client/y-websockets-client.js"></script>
<script src="../bower_components/y-memory/y-memory.js"></script>
<script src="../node_modules/monaco-editor/min/vs/loader.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,31 +0,0 @@
/* global Y */
require.config({ paths: { 'vs': '../node_modules/monaco-editor/min/vs' }})
require(['vs/editor/editor.main'], function() {
// Initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'monaco-example'
},
sourceDir: '/bower_components',
share: {
monaco: 'Text' // y.share.monaco is of type Y.Text
}
}).then(function (y) {
window.yMonaco = y
// Create Monaco editor
var editor = monaco.editor.create(document.getElementById('monacoContainer'), {
language: 'javascript'
})
// Bind to y.share.monaco
y.share.monaco.bindMonaco(editor)
})
})

View File

@@ -1,10 +0,0 @@
{
"name": "examples",
"version": "0.0.0",
"description": "",
"author": "Kevin Jahns",
"license": "MIT",
"dependencies": {
"monaco-editor": "^0.8.3"
}
}

View File

@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!-- quill does not include dist files! We are using the hosted version instead -->
<!--link rel="stylesheet" href="../bower_components/quill/dist/quill.snow.css" /-->
<link href="https://cdn.quilljs.com/1.0.4/quill.snow.css" rel="stylesheet">
<link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" rel="stylesheet">
<link href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/styles/monokai-sublime.min.css" rel="stylesheet">
<style>
#quill-container {
border: 1px solid gray;
box-shadow: 0px 0px 10px gray;
}
</style>
</head>
<body>
<div id="quill-container">
<div id="quill">
</div>
</div>
<script src="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.js" type="text/javascript"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/highlight.min.js" type="text/javascript"></script>
<script src="https://cdn.quilljs.com/1.0.4/quill.js"></script>
<!-- quill does not include dist files! We are using the hosted version instead (see above)
<script src="../bower_components/quill/dist/quill.js"></script>
-->
<script src="../bower_components/yjs/y.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,39 +0,0 @@
/* global Y, Quill */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'richtext-example-quill-1.0-test'
},
sourceDir: '/bower_components',
share: {
richtext: 'Richtext' // y.share.richtext is of type Y.Richtext
}
}).then(function (y) {
window.yQuill = y
// create quill element
window.quill = new Quill('#quill', {
modules: {
formula: true,
syntax: true,
toolbar: [
[{ size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{list: 'ordered' }]
]
},
theme: 'snow'
});
// bind quill to richtext type
y.share.richtext.bind(window.quill)
})

View File

@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!-- quill does not include dist files! We are using the hosted version instead -->
<!--link rel="stylesheet" href="../bower_components/quill/dist/quill.snow.css" /-->
<link href="https://cdn.quilljs.com/1.0.4/quill.snow.css" rel="stylesheet">
<link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" rel="stylesheet">
<link href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/styles/monokai-sublime.min.css" rel="stylesheet">
<style>
#quill-container {
border: 1px solid gray;
box-shadow: 0px 0px 10px gray;
}
</style>
</head>
<body>
<div id="quill-container">
<div id="quill">
</div>
</div>
<script src="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.js" type="text/javascript"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/highlight.min.js" type="text/javascript"></script>
<script src="https://cdn.quilljs.com/1.0.4/quill.js"></script>
<!-- quill does not include dist files! We are using the hosted version instead (see above)
<script src="../bower_components/quill/dist/quill.js"></script>
-->
<script src="../bower_components/yjs/y.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,49 +0,0 @@
/* global Y, Quill */
// register yjs service worker
if('serviceWorker' in navigator){
// Register service worker
// it is important to copy yjs-sw-template to the root directory!
navigator.serviceWorker.register('./yjs-sw-template.js').then(function(reg){
console.log("Yjs service worker registration succeeded. Scope is " + reg.scope);
}).catch(function(err){
console.error("Yjs service worker registration failed with error " + err);
})
}
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'serviceworker',
room: 'ServiceWorkerExample2'
},
sourceDir: '/bower_components',
share: {
richtext: 'Richtext' // y.share.richtext is of type Y.Richtext
}
}).then(function (y) {
window.yServiceWorker = y
// create quill element
window.quill = new Quill('#quill', {
modules: {
formula: true,
syntax: true,
toolbar: [
[{ size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{list: 'ordered' }]
]
},
theme: 'snow'
})
// bind quill to richtext type
y.share.richtext.bind(window.quill)
})

View File

@@ -1,22 +0,0 @@
/* eslint-env worker */
// copy and modify this file
self.DBConfig = {
name: 'indexeddb'
}
self.ConnectorConfig = {
name: 'websockets-client',
// url: '..',
options: {
jsonp: false
}
}
importScripts(
'/bower_components/yjs/y.js',
'/bower_components/y-memory/y-memory.js',
'/bower_components/y-indexeddb/y-indexeddb.js',
'/bower_components/y-websockets-client/y-websockets-client.js',
'/bower_components/y-serviceworker/yjs-sw-include.js'
)

View File

@@ -1,8 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<textarea style="width:80%;" rows=40 id="textfield" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
<script src="../bower_components/yjs/y.js"></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
/* global Y */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'Textarea-example'
// url: '127.0.0.1:1234'
},
sourceDir: '/bower_components',
share: {
textarea: 'Text' // y.share.textarea is of type Y.Text
}
}).then(function (y) {
window.yTextarea = y
// bind the textarea to a shared text element
y.share.textarea.bind(document.getElementById('textfield'))
// thats it..
})

View File

@@ -1,39 +0,0 @@
<!DOCTYPE html>
<html>
</head>
<script src="../bower_components/yjs/y.js"></script>
<script src="../bower_components/jquery/dist/jquery.min.js"></script>
<script src="./index.js"></script>
</head>
<body>
<h1> Shared DOM Example </h1>
<p> Use native DOM function or jQuery to manipulate the shared DOM (window.sharedDom). </p>
<div class="command">
<button type="button">Execute</button>
<input type="text" value='$(sharedDom).append("<h3>Appended headline</h3>")' size="40"/>
</div>
<div class="command">
<button type="button">Execute</button>
<input type="text" value='$(sharedDom).attr("align","right")' size="40"/>
</div>
<div class="command">
<button type="button">Execute</button>
<input type="text" value='$(sharedDom).attr("style","color:blue;")' size="40"/>
</div>
<script>
var commands = document.querySelectorAll(".command");
Array.prototype.forEach.call(document.querySelectorAll('.command'), function (command) {
var execute = function(){
eval(command.querySelector("input").value);
}
command.querySelector("button").onclick = execute
$(command.querySelector("input")).keyup(function (e) {
if (e.keyCode == 13) {
execute()
}
})
})
</script>
</body>
</html>

View File

@@ -1,21 +0,0 @@
/* global Y */
// initialize a shared object. This function call returns a promise!
Y({
db: {
name: 'memory'
},
connector: {
name: 'websockets-client',
room: 'Xml-example'
},
sourceDir: '/bower_components',
share: {
xml: 'Xml("p")' // y.share.xml is of type Y.Xml with tagname "p"
}
}).then(function (y) {
window.yXml = y
// bind xml type to a dom, and put it in body
window.sharedDom = y.share.xml.getDom()
document.body.appendChild(window.sharedDom)
})

4578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,75 @@
{
"name": "yjs",
"version": "13.0.0-0",
"description": "A framework for real-time p2p shared editing on any data",
"main": "./src/y.js",
"version": "13.0.3",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"sideEffects": false,
"scripts": {
"lint": "standard",
"dist": "rollup -c rollup.dist.js",
"serve": "concurrently 'serve examples' 'rollup -wc rollup.dist.js -o examples/bower_components/yjs/y.js'"
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
"dist": "rm -rf dist && rollup -c && tsc",
"watch": "rollup -wc",
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && 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"
},
"pre-commit": [
"lint",
"test"
"files": [
"dist/*",
"src/*",
"tests/*",
"docs/*"
],
"dictionaries": {
"doc": "docs",
"test": "tests"
},
"standard": {
"ignore": [
"./y.js",
"./y.js.map"
"/dist",
"/node_modules",
"/docs"
]
},
"repository": {
"type": "git",
"url": "https://github.com/y-js/yjs.git"
"url": "https://github.com/yjs/yjs.git"
},
"keywords": [
"Yjs",
"OT",
"Collaboration",
"Synchronization",
"ShareJS",
"Coweb",
"Concurrency"
"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"
},
"homepage": "http://y-js.org",
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-preset-latest": "^6.24.1",
"babel-plugin-external-helpers": "^6.22.0",
"babel-plugin-transform-regenerator": "^6.24.1",
"babel-plugin-transform-runtime": "^6.23.0",
"chance": "^1.0.9",
"concurrently": "^3.4.0",
"rollup-plugin-babel": "^2.7.1",
"rollup-plugin-commonjs": "^8.0.2",
"rollup-plugin-inject": "^2.0.0",
"rollup-plugin-multi-entry": "^2.0.1",
"rollup-plugin-node-resolve": "^3.0.0",
"rollup-plugin-uglify": "^1.0.2",
"rollup-regenerator-runtime": "^6.23.1",
"rollup-watch": "^3.2.2",
"standard": "^10.0.2"
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "https://yjs.dev",
"dependencies": {
"debug": "^2.6.8"
"lib0": "^0.2.12"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^11.0.1",
"@rollup/plugin-node-resolve": "^7.0.0",
"concurrently": "^3.6.1",
"http-server": "^0.12.1",
"jsdoc": "^3.6.3",
"markdownlint-cli": "^0.19.0",
"rollup": "^1.30.0",
"rollup-cli": "^1.0.9",
"standard": "^14.0.0",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^3.7.5",
"y-protocols": "^0.2.0"
}
}

102
rollup.config.js Normal file
View File

@@ -0,0 +1,102 @@
import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
const localImports = process.env.LOCALIMPORTS
const customModules = new Set([
'y-websocket',
'y-codemirror',
'y-ace',
'y-textarea',
'y-quill',
'y-dom',
'y-prosemirror'
])
/**
* @type {Set<any>}
*/
const customLibModules = new Set([
'lib0',
'y-protocols'
])
const debugResolve = {
resolveId (importee) {
if (importee === 'yjs') {
return `${process.cwd()}/src/index.js`
}
if (localImports) {
if (customModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}/src/${importee}.js`
}
if (customLibModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}`
}
}
return null
}
}
export default [{
input: './src/index.js',
output: {
name: 'Y',
file: 'dist/yjs.cjs',
format: 'cjs',
sourcemap: true,
paths: path => {
if (/^lib0\//.test(path)) {
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: 'esm',
sourcemap: true,
paths: path => {
if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5, -3)}.cjs`
}
return path
}
},
external: id => /^lib0\//.test(id)
}, {
input: './tests/index.js',
output: {
name: 'test',
file: 'dist/tests.js',
format: 'iife',
sourcemap: true
},
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({
sourcemap: true,
mainFields: ['module', 'main']
}),
commonjs()
],
external: ['isomorphic.js']
}]

View File

@@ -1,47 +0,0 @@
import inject from 'rollup-plugin-inject'
import babel from 'rollup-plugin-babel'
import uglify from 'rollup-plugin-uglify'
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
var pkg = require('./package.json')
export default {
entry: 'src/y.js',
moduleName: 'Y',
format: 'umd',
plugins: [
nodeResolve({
main: true,
module: true,
browser: true
}),
commonjs(),
babel({
runtimeHelpers: true
}),
inject({
regeneratorRuntime: 'regenerator-runtime'
}),
uglify({
output: {
comments: function (node, comment) {
var text = comment.value
var type = comment.type
if (type === 'comment2') {
// multiline comment
return /@license/i.test(text)
}
}
}
})
],
dest: 'y.js',
sourceMap: true,
banner: `
/**
* ${pkg.name} - ${pkg.description}
* @version v${pkg.version}
* @license ${pkg.license}
*/
`
}

View File

@@ -1,20 +0,0 @@
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
import multiEntry from 'rollup-plugin-multi-entry'
export default {
entry: 'tests/*.js',
moduleName: 'y-array-tests',
format: 'umd',
plugins: [
nodeResolve({
main: true,
module: true,
browser: true
}),
commonjs(),
multiEntry()
],
dest: 'y-array.test.js',
sourceMap: true
}

View File

@@ -1,493 +0,0 @@
/* @flow */
'use strict'
function canRead (auth) { return auth === 'read' || auth === 'write' }
function canWrite (auth) { return auth === 'write' }
export default function extendConnector (Y/* :any */) {
class AbstractConnector {
/* ::
y: YConfig;
role: SyncRole;
connections: Object;
isSynced: boolean;
userEventListeners: Array<Function>;
whenSyncedListeners: Array<Function>;
currentSyncTarget: ?UserId;
syncingClients: Array<UserId>;
forwardToSyncingClients: boolean;
debug: boolean;
syncStep2: Promise;
userId: UserId;
send: Function;
broadcast: Function;
broadcastOpBuffer: Array<Operation>;
protocolVersion: number;
*/
/*
opts contains the following information:
role : String Role of this client ("master" or "slave")
userId : String Uniquely defines the user.
debug: Boolean Whether to print debug messages (optional)
*/
constructor (y, opts) {
this.y = y
if (opts == null) {
opts = {}
}
// Prefer to receive untransformed operations. This does only work if
// this client receives operations from only one other client.
// In particular, this does not work with y-webrtc.
// It will work with y-websockets-client
this.preferUntransformed = opts.preferUntransformed || false
if (opts.role == null || opts.role === 'master') {
this.role = 'master'
} else if (opts.role === 'slave') {
this.role = 'slave'
} else {
throw new Error("Role must be either 'master' or 'slave'!")
}
this.log = Y.debug('y:connector')
this.logMessage = Y.debug('y:connector-message')
this.y.db.forwardAppliedOperations = opts.forwardAppliedOperations || false
this.role = opts.role
this.connections = {}
this.isSynced = false
this.userEventListeners = []
this.whenSyncedListeners = []
this.currentSyncTarget = null
this.syncingClients = []
this.forwardToSyncingClients = opts.forwardToSyncingClients !== false
this.debug = opts.debug === true
this.broadcastOpBuffer = []
this.protocolVersion = 11
this.authInfo = opts.auth || null
this.checkAuth = opts.checkAuth || function () { return Promise.resolve('write') } // default is everyone has write access
if (opts.generateUserId === true) {
this.setUserId(Y.utils.generateGuid())
}
}
resetAuth (auth) {
if (this.authInfo !== auth) {
this.authInfo = auth
this.broadcast({
type: 'auth',
auth: this.authInfo
})
}
}
reconnect () {
this.log('reconnecting..')
return this.y.db.startGarbageCollector()
}
disconnect () {
this.log('discronnecting..')
this.connections = {}
this.isSynced = false
this.currentSyncTarget = null
this.syncingClients = []
this.whenSyncedListeners = []
this.y.db.stopGarbageCollector()
return this.y.db.whenTransactionsFinished()
}
repair () {
this.log('Repairing the state of Yjs. This can happen if messages get lost, and Yjs detects that something is wrong. If this happens often, please report an issue here: https://github.com/y-js/yjs/issues')
for (var name in this.connections) {
this.connections[name].isSynced = false
}
this.isSynced = false
this.currentSyncTarget = null
this.findNextSyncTarget()
}
setUserId (userId) {
if (this.userId == null) {
this.log('Set userId to "%s"', userId)
this.userId = userId
return this.y.db.setUserId(userId)
} else {
return null
}
}
onUserEvent (f) {
this.userEventListeners.push(f)
}
removeUserEventListener (f) {
this.userEventListeners = this.userEventListeners.filter(g => f !== g)
}
userLeft (user) {
if (this.connections[user] != null) {
this.log('User left: %s', user)
delete this.connections[user]
if (user === this.currentSyncTarget) {
this.currentSyncTarget = null
this.findNextSyncTarget()
}
this.syncingClients = this.syncingClients.filter(function (cli) {
return cli !== user
})
for (var f of this.userEventListeners) {
f({
action: 'userLeft',
user: user
})
}
}
}
userJoined (user, role) {
if (role == null) {
throw new Error('You must specify the role of the joined user!')
}
if (this.connections[user] != null) {
throw new Error('This user already joined!')
}
this.log('User joined: %s', user)
this.connections[user] = {
isSynced: false,
role: role
}
let defer = {}
defer.promise = new Promise(function (resolve) { defer.resolve = resolve })
this.connections[user].syncStep2 = defer
for (var f of this.userEventListeners) {
f({
action: 'userJoined',
user: user,
role: role
})
}
if (this.currentSyncTarget == null) {
this.findNextSyncTarget()
}
}
// Execute a function _when_ we are connected.
// If not connected, wait until connected
whenSynced (f) {
if (this.isSynced) {
f()
} else {
this.whenSyncedListeners.push(f)
}
}
findNextSyncTarget () {
if (this.currentSyncTarget != null) {
return // "The current sync has not finished!"
}
var syncUser = null
for (var uid in this.connections) {
if (!this.connections[uid].isSynced) {
syncUser = uid
break
}
}
var conn = this
if (syncUser != null) {
this.currentSyncTarget = syncUser
this.y.db.requestTransaction(function * () {
var stateSet = yield * this.getStateSet()
var deleteSet = yield * this.getDeleteSet()
var answer = {
type: 'sync step 1',
stateSet: stateSet,
// deleteSet: deleteSet,
protocolVersion: conn.protocolVersion,
auth: conn.authInfo
}
if (conn.preferUntransformed && Object.keys(stateSet).length === 0) {
answer.preferUntransformed = true
}
conn.send(syncUser, answer)
})
} else {
if (!conn.isSynced) {
this.y.db.requestTransaction(function * () {
if (!conn.isSynced) {
// it is crucial that isSynced is set at the time garbageCollectAfterSync is called
conn.isSynced = true
// It is safer to remove this!
// TODO: remove: yield * this.garbageCollectAfterSync()
// call whensynced listeners
for (var f of conn.whenSyncedListeners) {
f()
}
conn.whenSyncedListeners = []
}
})
}
}
}
send (uid, message) {
this.log('Send \'%s\' to %s', message.type, uid)
this.logMessage('Message: %j', message)
}
broadcast (message) {
this.log('Broadcast \'%s\'', message.type)
this.logMessage('Message: %j', message)
}
/*
Buffer operations, and broadcast them when ready.
*/
broadcastOps (ops) {
ops = ops.map(function (op) {
return Y.Struct[op.struct].encode(op)
})
var self = this
function broadcastOperations () {
if (self.broadcastOpBuffer.length > 0) {
self.broadcast({
type: 'update',
ops: self.broadcastOpBuffer
})
self.broadcastOpBuffer = []
}
}
if (this.broadcastOpBuffer.length === 0) {
this.broadcastOpBuffer = ops
this.y.db.whenTransactionsFinished().then(broadcastOperations)
} else {
this.broadcastOpBuffer = this.broadcastOpBuffer.concat(ops)
}
}
/*
You received a raw message, and you know that it is intended for Yjs. Then call this function.
*/
receiveMessage (sender/* :UserId */, message/* :Message */) {
if (sender === this.userId) {
return Promise.resolve()
}
this.log('Receive \'%s\' from %s', message.type, sender)
this.logMessage('Message: %j', message)
if (message.protocolVersion != null && message.protocolVersion !== this.protocolVersion) {
this.log(
`You tried to sync with a yjs instance that has a different protocol version
(You: ${this.protocolVersion}, Client: ${message.protocolVersion}).
The sync was stopped. You need to upgrade your dependencies (especially Yjs & the Connector)!
`)
this.send(sender, {
type: 'sync stop',
protocolVersion: this.protocolVersion
})
return Promise.reject(new Error('Incompatible protocol version'))
}
if (message.auth != null && this.connections[sender] != null) {
// authenticate using auth in message
var auth = this.checkAuth(message.auth, this.y)
this.connections[sender].auth = auth
auth.then(auth => {
for (var f of this.userEventListeners) {
f({
action: 'userAuthenticated',
user: sender,
auth: auth
})
}
})
} else if (this.connections[sender] != null && this.connections[sender].auth == null) {
// authenticate without otherwise
this.connections[sender].auth = this.checkAuth(null, this.y)
}
if (this.connections[sender] != null && this.connections[sender].auth != null) {
return this.connections[sender].auth.then((auth) => {
if (message.type === 'sync step 1' && canRead(auth)) {
let conn = this
let m = message
let wait // wait for sync step 2 to complete
if (this.role === 'slave') {
wait = Promise.all(Object.keys(this.connections)
.map(uid => this.connections[uid])
.filter(conn => conn.role === 'master')
.map(conn => conn.syncStep2.promise)
)
} else {
wait = Promise.resolve()
}
wait.then(() => {
this.y.db.requestTransaction(function * () {
var currentStateSet = yield * this.getStateSet()
// TODO: remove
// if (canWrite(auth)) {
// yield * this.applyDeleteSet(m.deleteSet)
// }
var ds = yield * this.getDeleteSet()
var answer = {
type: 'sync step 2',
stateSet: currentStateSet,
deleteSet: ds,
protocolVersion: this.protocolVersion,
auth: this.authInfo
}
if (message.preferUntransformed === true && Object.keys(m.stateSet).length === 0) {
answer.osUntransformed = yield * this.getOperationsUntransformed()
} else {
answer.os = yield * this.getOperations(m.stateSet)
}
conn.send(sender, answer)
if (this.forwardToSyncingClients) {
conn.syncingClients.push(sender)
setTimeout(function () {
conn.syncingClients = conn.syncingClients.filter(function (cli) {
return cli !== sender
})
conn.send(sender, {
type: 'sync done'
})
}, 5000) // TODO: conn.syncingClientDuration)
} else {
conn.send(sender, {
type: 'sync done'
})
}
})
})
} else if (message.type === 'sync step 2' && canWrite(auth)) {
var db = this.y.db
let defer = this.connections[sender].syncStep2
let m = message
// apply operations first
db.requestTransaction(function * () {
yield * this.applyDeleteSet(m.deleteSet)
if (m.osUntransformed != null) {
yield * this.applyOperationsUntransformed(m.osUntransformed, m.stateSet)
} else {
this.store.apply(m.os)
}
defer.resolve()
})
/*/ then apply ds
db.whenTransactionsFinished().then(() => {
db.requestTransaction(function * () {
yield * this.applyDeleteSet(m.deleteSet)
})
defer.resolve()
})*/
return defer.promise
} else if (message.type === 'sync done') {
var self = this
this.connections[sender].syncStep2.promise.then(function () {
self._setSyncedWith(sender)
})
} else if (message.type === 'update' && canWrite(auth)) {
if (this.forwardToSyncingClients) {
for (var client of this.syncingClients) {
this.send(client, message)
}
}
if (this.y.db.forwardAppliedOperations) {
var delops = message.ops.filter(function (o) {
return o.struct === 'Delete'
})
if (delops.length > 0) {
this.broadcastOps(delops)
}
}
this.y.db.apply(message.ops)
}
})
} else {
return Promise.reject(new Error('Unable to deliver message'))
}
}
_setSyncedWith (user) {
var conn = this.connections[user]
if (conn != null) {
conn.isSynced = true
}
if (user === this.currentSyncTarget) {
this.currentSyncTarget = null
this.findNextSyncTarget()
}
}
/*
Currently, the HB encodes operations as JSON. For the moment I want to keep it
that way. Maybe we support encoding in the HB as XML in the future, but for now I don't want
too much overhead. Y is very likely to get changed a lot in the future
Because we don't want to encode JSON as string (with character escaping, wich makes it pretty much unreadable)
we encode the JSON as XML.
When the HB support encoding as XML, the format should look pretty much like this.
does not support primitive values as array elements
expects an ltx (less than xml) object
*/
parseMessageFromXml (m/* :any */) {
function parseArray (node) {
for (var n of node.children) {
if (n.getAttribute('isArray') === 'true') {
return parseArray(n)
} else {
return parseObject(n)
}
}
}
function parseObject (node/* :any */) {
var json = {}
for (var attrName in node.attrs) {
var value = node.attrs[attrName]
var int = parseInt(value, 10)
if (isNaN(int) || ('' + int) !== value) {
json[attrName] = value
} else {
json[attrName] = int
}
}
for (var n/* :any */ in node.children) {
var name = n.name
if (n.getAttribute('isArray') === 'true') {
json[name] = parseArray(n)
} else {
json[name] = parseObject(n)
}
}
return json
}
parseObject(m)
}
/*
encode message in xml
we use string because Strophe only accepts an "xml-string"..
So {a:4,b:{c:5}} will look like
<y a="4">
<b c="5"></b>
</y>
m - ltx element
json - Object
*/
encodeMessageToXml (msg, obj) {
// attributes is optional
function encodeObject (m, json) {
for (var name in json) {
var value = json[name]
if (name == null) {
// nop
} else if (value.constructor === Object) {
encodeObject(m.c(name), value)
} else if (value.constructor === Array) {
encodeArray(m.c(name), value)
} else {
m.setAttribute(name, value)
}
}
}
function encodeArray (m, array) {
m.setAttribute('isArray', 'true')
for (var e of array) {
if (e.constructor === Object) {
encodeObject(m.c('array-element'), e)
} else {
encodeArray(m.c('array-element'), e)
}
}
}
if (obj.constructor === Object) {
encodeObject(msg.c('y', { xmlns: 'http://y.ninja/connector-stanza' }), obj)
} else if (obj.constructor === Array) {
encodeArray(msg.c('y', { xmlns: 'http://y.ninja/connector-stanza' }), obj)
} else {
throw new Error("I can't encode this json!")
}
}
}
Y.AbstractConnector = AbstractConnector
}

View File

@@ -1,178 +0,0 @@
/* global getRandom, async */
'use strict'
module.exports = function (Y) {
var globalRoom = {
users: {},
buffers: {},
removeUser: function (user) {
for (var i in this.users) {
this.users[i].userLeft(user)
}
delete this.users[user]
delete this.buffers[user]
},
addUser: function (connector) {
this.users[connector.userId] = connector
this.buffers[connector.userId] = {}
for (var uname in this.users) {
if (uname !== connector.userId) {
var u = this.users[uname]
u.userJoined(connector.userId, 'master')
connector.userJoined(u.userId, 'master')
}
}
},
whenTransactionsFinished: function () {
var self = this
return new Promise(function (resolve, reject) {
// The connector first has to send the messages to the db.
// Wait for the checkAuth-function to resolve
// The test lib only has a simple checkAuth function: `() => Promise.resolve()`
// Just add a function to the event-queue, in order to wait for the event.
// TODO: this may be buggy in test applications (but it isn't be for real-life apps)
setTimeout(function () {
var ps = []
for (var name in self.users) {
ps.push(self.users[name].y.db.whenTransactionsFinished())
}
Promise.all(ps).then(resolve, reject)
}, 10)
})
},
flushOne: function flushOne () {
var bufs = []
for (var receiver in globalRoom.buffers) {
let buff = globalRoom.buffers[receiver]
var push = false
for (let sender in buff) {
if (buff[sender].length > 0) {
push = true
break
}
}
if (push) {
bufs.push(receiver)
}
}
if (bufs.length > 0) {
var userId = getRandom(bufs)
let buff = globalRoom.buffers[userId]
let sender = getRandom(Object.keys(buff))
var m = buff[sender].shift()
if (buff[sender].length === 0) {
delete buff[sender]
}
var user = globalRoom.users[userId]
return user.receiveMessage(m[0], m[1]).then(function () {
return user.y.db.whenTransactionsFinished()
}, function () {})
} else {
return false
}
},
flushAll: function () {
return new Promise(function (resolve) {
// flushes may result in more created operations,
// flush until there is nothing more to flush
function nextFlush () {
var c = globalRoom.flushOne()
if (c) {
while (c) {
c = globalRoom.flushOne()
}
globalRoom.whenTransactionsFinished().then(nextFlush)
} else {
c = globalRoom.flushOne()
if (c) {
c.then(function () {
globalRoom.whenTransactionsFinished().then(nextFlush)
})
} else {
resolve()
}
}
}
globalRoom.whenTransactionsFinished().then(nextFlush)
})
}
}
Y.utils.globalRoom = globalRoom
var userIdCounter = 0
class Test extends Y.AbstractConnector {
constructor (y, options) {
if (options === undefined) {
throw new Error('Options must not be undefined!')
}
options.role = 'master'
options.forwardToSyncingClients = false
super(y, options)
this.setUserId((userIdCounter++) + '').then(() => {
globalRoom.addUser(this)
})
this.globalRoom = globalRoom
this.syncingClientDuration = 0
}
receiveMessage (sender, m) {
return super.receiveMessage(sender, JSON.parse(JSON.stringify(m)))
}
send (userId, message) {
var buffer = globalRoom.buffers[userId]
if (buffer != null) {
if (buffer[this.userId] == null) {
buffer[this.userId] = []
}
buffer[this.userId].push(JSON.parse(JSON.stringify([this.userId, message])))
}
}
broadcast (message) {
for (var key in globalRoom.buffers) {
var buff = globalRoom.buffers[key]
if (buff[this.userId] == null) {
buff[this.userId] = []
}
buff[this.userId].push(JSON.parse(JSON.stringify([this.userId, message])))
}
}
isDisconnected () {
return globalRoom.users[this.userId] == null
}
reconnect () {
if (this.isDisconnected()) {
globalRoom.addUser(this)
super.reconnect()
}
return Y.utils.globalRoom.flushAll()
}
disconnect () {
var waitForMe = Promise.resolve()
if (!this.isDisconnected()) {
globalRoom.removeUser(this.userId)
waitForMe = super.disconnect()
}
var self = this
return waitForMe.then(function () {
return self.y.db.whenTransactionsFinished()
})
}
flush () {
var self = this
return async(function * () {
var buff = globalRoom.buffers[self.userId]
while (Object.keys(buff).length > 0) {
var sender = getRandom(Object.keys(buff))
var m = buff[sender].shift()
if (buff[sender].length === 0) {
delete buff[sender]
}
yield this.receiveMessage(m[0], m[1])
}
yield self.whenTransactionsFinished()
})
}
}
Y.Test = Test
}

View File

@@ -1,609 +0,0 @@
/* @flow */
'use strict'
export default function extendDatabase (Y /* :any */) {
/*
Partial definition of an OperationStore.
TODO: name it Database, operation store only holds operations.
A database definition must alse define the following methods:
* logTable() (optional)
- show relevant information information in a table
* requestTransaction(makeGen)
- request a transaction
* destroy()
- destroy the database
*/
class AbstractDatabase {
/* ::
y: YConfig;
forwardAppliedOperations: boolean;
listenersById: Object;
listenersByIdExecuteNow: Array<Object>;
listenersByIdRequestPending: boolean;
initializedTypes: Object;
whenUserIdSetListener: ?Function;
waitingTransactions: Array<Transaction>;
transactionInProgress: boolean;
executeOrder: Array<Object>;
gc1: Array<Struct>;
gc2: Array<Struct>;
gcTimeout: number;
gcInterval: any;
garbageCollect: Function;
executeOrder: Array<any>; // for debugging only
userId: UserId;
opClock: number;
transactionsFinished: ?{promise: Promise, resolve: any};
transact: (x: ?Generator) => any;
*/
constructor (y, opts) {
this.y = y
opts.gc = opts.gc === true
this.dbOpts = opts
var os = this
this.userId = null
var resolve_
this.userIdPromise = new Promise(function (resolve) {
resolve_ = resolve
})
this.userIdPromise.resolve = resolve_
// whether to broadcast all applied operations (insert & delete hook)
this.forwardAppliedOperations = false
// E.g. this.listenersById[id] : Array<Listener>
this.listenersById = {}
// Execute the next time a transaction is requested
this.listenersByIdExecuteNow = []
// A transaction is requested
this.listenersByIdRequestPending = false
/* To make things more clear, the following naming conventions:
* ls : we put this.listenersById on ls
* l : Array<Listener>
* id : Id (can't use as property name)
* sid : String (converted from id via JSON.stringify
so we can use it as a property name)
Always remember to first overwrite
a property before you iterate over it!
*/
// TODO: Use ES7 Weak Maps. This way types that are no longer user,
// wont be kept in memory.
this.initializedTypes = {}
this.waitingTransactions = []
this.transactionInProgress = false
this.transactionIsFlushed = false
if (typeof YConcurrencyTestingMode !== 'undefined') {
this.executeOrder = []
}
this.gc1 = [] // first stage
this.gc2 = [] // second stage -> after that, remove the op
function garbageCollect () {
return os.whenTransactionsFinished().then(function () {
if (os.gcTimeout > 0 && (os.gc1.length > 0 || os.gc2.length > 0)) {
// debug
if (os.y.connector.isSynced === false) {
debugger
}
if (!os.y.connector.isSynced) {
console.warn('gc should be empty when not synced!')
}
return new Promise((resolve) => {
os.requestTransaction(function * () {
if (os.y.connector != null && os.y.connector.isSynced) {
for (var i = 0; i < os.gc2.length; i++) {
var oid = os.gc2[i]
yield * this.garbageCollectOperation(oid)
}
os.gc2 = os.gc1
os.gc1 = []
}
// TODO: Use setInterval here instead (when garbageCollect is called several times there will be several timeouts..)
if (os.gcTimeout > 0) {
os.gcInterval = setTimeout(garbageCollect, os.gcTimeout)
}
resolve()
})
})
} else {
// TODO: see above
if (os.gcTimeout > 0) {
os.gcInterval = setTimeout(garbageCollect, os.gcTimeout)
}
return Promise.resolve()
}
})
}
this.garbageCollect = garbageCollect
this.startGarbageCollector()
this.repairCheckInterval = !opts.repairCheckInterval ? 6000 : opts.repairCheckInterval
this.opsReceivedTimestamp = new Date()
this.startRepairCheck()
}
startGarbageCollector () {
this.gc = this.dbOpts.gc
if (this.gc) {
this.gcTimeout = !this.dbOpts.gcTimeout ? 50000 : this.dbOpts.gcTimeout
} else {
this.gcTimeout = -1
}
if (this.gcTimeout > 0) {
this.garbageCollect()
}
}
startRepairCheck () {
var os = this
if (this.repairCheckInterval > 0) {
this.repairCheckIntervalHandler = setInterval(function repairOnMissingOperations () {
/*
Case 1. No ops have been received in a while (new Date() - os.opsReceivedTimestamp > os.repairCheckInterval)
- 1.1 os.listenersById is empty. Then the state was correct the whole time. -> Nothing to do (nor to update)
- 1.2 os.listenersById is not empty.
* Then the state was incorrect for at least {os.repairCheckInterval} seconds.
* -> Remove everything in os.listenersById and sync again (connector.repair())
Case 2. An op has been received in the last {os.repairCheckInterval } seconds.
It is not yet necessary to check for faulty behavior. Everything can still resolve itself. Wait for more messages.
If nothing was received for a while and os.listenersById is still not emty, we are in case 1.2
-> Do nothing
Baseline here is: we really only have to catch case 1.2..
*/
if (
new Date() - os.opsReceivedTimestamp > os.repairCheckInterval &&
Object.keys(os.listenersById).length > 0 // os.listenersById is not empty
) {
// haven't received operations for over {os.repairCheckInterval} seconds, resend state vector
os.listenersById = {}
os.opsReceivedTimestamp = new Date() // update so you don't send repair several times in a row
os.y.connector.repair()
}
}, this.repairCheckInterval)
}
}
stopRepairCheck () {
clearInterval(this.repairCheckIntervalHandler)
}
queueGarbageCollector (id) {
if (this.y.connector.isSynced && this.gc) {
this.gc1.push(id)
}
}
emptyGarbageCollector () {
return new Promise(resolve => {
var check = () => {
if (this.gc1.length > 0 || this.gc2.length > 0) {
this.garbageCollect().then(check)
} else {
resolve()
}
}
setTimeout(check, 0)
})
}
addToDebug () {
if (typeof YConcurrencyTestingMode !== 'undefined') {
var command /* :string */ = Array.prototype.map.call(arguments, function (s) {
if (typeof s === 'string') {
return s
} else {
return JSON.stringify(s)
}
}).join('').replace(/"/g, "'").replace(/,/g, ', ').replace(/:/g, ': ')
this.executeOrder.push(command)
}
}
getDebugData () {
console.log(this.executeOrder.join('\n'))
}
stopGarbageCollector () {
var self = this
this.gc = false
this.gcTimeout = -1
return new Promise(function (resolve) {
self.requestTransaction(function * () {
var ungc /* :Array<Struct> */ = self.gc1.concat(self.gc2)
self.gc1 = []
self.gc2 = []
for (var i = 0; i < ungc.length; i++) {
var op = yield * this.getOperation(ungc[i])
if (op != null) {
delete op.gc
yield * this.setOperation(op)
}
}
resolve()
})
})
}
/*
Try to add to GC.
TODO: rename this function
Rulez:
* Only gc if this user is online & gc turned on
* The most left element in a list must not be gc'd.
=> There is at least one element in the list
returns true iff op was added to GC
*/
* addToGarbageCollector (op, left) {
if (
op.gc == null &&
op.deleted === true &&
this.store.gc &&
this.store.y.connector.isSynced
) {
var gc = false
if (left != null && left.deleted === true) {
gc = true
} else if (op.content != null && op.content.length > 1) {
op = yield * this.getInsertionCleanStart([op.id[0], op.id[1] + 1])
gc = true
}
if (gc) {
op.gc = true
yield * this.setOperation(op)
this.store.queueGarbageCollector(op.id)
return true
}
}
return false
}
removeFromGarbageCollector (op) {
function filter (o) {
return !Y.utils.compareIds(o, op.id)
}
this.gc1 = this.gc1.filter(filter)
this.gc2 = this.gc2.filter(filter)
delete op.gc
}
destroyTypes () {
for (var key in this.initializedTypes) {
var type = this.initializedTypes[key]
if (type._destroy != null) {
type._destroy()
} else {
console.error('The type you included does not provide destroy functionality, it will remain in memory (updating your packages will help).')
}
}
}
* destroy () {
clearTimeout(this.gcInterval)
this.gcInterval = null
this.stopRepairCheck()
}
setUserId (userId) {
if (!this.userIdPromise.inProgress) {
this.userIdPromise.inProgress = true
var self = this
self.requestTransaction(function * () {
self.userId = userId
var state = yield * this.getState(userId)
self.opClock = state.clock
self.userIdPromise.resolve(userId)
})
}
return this.userIdPromise
}
whenUserIdSet (f) {
this.userIdPromise.then(f)
}
getNextOpId (numberOfIds) {
if (numberOfIds == null) {
throw new Error('getNextOpId expects the number of created ids to create!')
} else if (this.userId == null) {
throw new Error('OperationStore not yet initialized!')
} else {
var id = [this.userId, this.opClock]
this.opClock += numberOfIds
return id
}
}
/*
Apply a list of operations.
* we save a timestamp, because we received new operations that could resolve ops in this.listenersById (see this.startRepairCheck)
* get a transaction
* check whether all Struct.*.requiredOps are in the OS
* check if it is an expected op (otherwise wait for it)
* check if was deleted, apply a delete operation after op was applied
*/
apply (ops) {
this.opsReceivedTimestamp = new Date()
for (var i = 0; i < ops.length; i++) {
var o = ops[i]
if (o.id == null || o.id[0] !== this.y.connector.userId) {
var required = Y.Struct[o.struct].requiredOps(o)
if (o.requires != null) {
required = required.concat(o.requires)
}
this.whenOperationsExist(required, o)
}
}
}
/*
op is executed as soon as every operation requested is available.
Note that Transaction can (and should) buffer requests.
*/
whenOperationsExist (ids, op) {
if (ids.length > 0) {
let listener = {
op: op,
missing: ids.length
}
for (let i = 0; i < ids.length; i++) {
let id = ids[i]
let sid = JSON.stringify(id)
let l = this.listenersById[sid]
if (l == null) {
l = []
this.listenersById[sid] = l
}
l.push(listener)
}
} else {
this.listenersByIdExecuteNow.push({
op: op
})
}
if (this.listenersByIdRequestPending) {
return
}
this.listenersByIdRequestPending = true
var store = this
this.requestTransaction(function * () {
var exeNow = store.listenersByIdExecuteNow
store.listenersByIdExecuteNow = []
var ls = store.listenersById
store.listenersById = {}
store.listenersByIdRequestPending = false
for (let key = 0; key < exeNow.length; key++) {
let o = exeNow[key].op
yield * store.tryExecute.call(this, o)
}
for (var sid in ls) {
var l = ls[sid]
var id = JSON.parse(sid)
var op
if (typeof id[1] === 'string') {
op = yield * this.getOperation(id)
} else {
op = yield * this.getInsertion(id)
}
if (op == null) {
store.listenersById[sid] = l
} else {
for (let i = 0; i < l.length; i++) {
let listener = l[i]
let o = listener.op
if (--listener.missing === 0) {
yield * store.tryExecute.call(this, o)
}
}
}
}
})
}
/*
Actually execute an operation, when all expected operations are available.
*/
/* :: // TODO: this belongs somehow to transaction
store: Object;
getOperation: any;
isGarbageCollected: any;
addOperation: any;
whenOperationsExist: any;
*/
* tryExecute (op) {
this.store.addToDebug('yield* this.store.tryExecute.call(this, ', JSON.stringify(op), ')')
if (op.struct === 'Delete') {
yield * Y.Struct.Delete.execute.call(this, op)
// this is now called in Transaction.deleteOperation!
// yield* this.store.operationAdded(this, op)
} else {
// check if this op was defined
var defined = yield * this.getInsertion(op.id)
while (defined != null && defined.content != null) {
// check if this op has a longer content in the case it is defined
if (defined.id[1] + defined.content.length < op.id[1] + op.content.length) {
var overlapSize = defined.content.length - (op.id[1] - defined.id[1])
op.content.splice(0, overlapSize)
op.id = [op.id[0], op.id[1] + overlapSize]
op.left = Y.utils.getLastId(defined)
op.origin = op.left
defined = yield * this.getOperation(op.id) // getOperation suffices here
} else {
break
}
}
if (defined == null) {
var opid = op.id
var isGarbageCollected = yield * this.isGarbageCollected(opid)
if (!isGarbageCollected) {
// TODO: reduce number of get / put calls for op ..
yield * Y.Struct[op.struct].execute.call(this, op)
yield * this.addOperation(op)
yield * this.store.operationAdded(this, op)
// operationAdded can change op..
op = yield * this.getOperation(opid)
// if insertion, try to combine with left
yield * this.tryCombineWithLeft(op)
}
}
}
}
/*
* Called by a transaction when an operation is added.
* This function is especially important for y-indexeddb, where several instances may share a single database.
* Every time an operation is created by one instance, it is send to all other instances and operationAdded is called
*
* If it's not a Delete operation:
* * Checks if another operation is executable (listenersById)
* * Update state, if possible
*
* Always:
* * Call type
*/
* operationAdded (transaction, op) {
if (op.struct === 'Delete') {
var type = this.initializedTypes[JSON.stringify(op.targetParent)]
if (type != null) {
yield * type._changed(transaction, op)
}
} else {
// increase SS
yield * transaction.updateState(op.id[0])
var opLen = op.content != null ? op.content.length : 1
for (let i = 0; i < opLen; i++) {
// notify whenOperation listeners (by id)
var sid = JSON.stringify([op.id[0], op.id[1] + i])
var l = this.listenersById[sid]
delete this.listenersById[sid]
if (l != null) {
for (var key in l) {
var listener = l[key]
if (--listener.missing === 0) {
this.whenOperationsExist([], listener.op)
}
}
}
}
var t = this.initializedTypes[JSON.stringify(op.parent)]
// if parent is deleted, mark as gc'd and return
if (op.parent != null) {
var parentIsDeleted = yield * transaction.isDeleted(op.parent)
if (parentIsDeleted) {
yield * transaction.deleteList(op.id)
return
}
}
// notify parent, if it was instanciated as a custom type
if (t != null) {
let o = Y.utils.copyOperation(op)
yield * t._changed(transaction, o)
}
if (!op.deleted) {
// Delete if DS says this is actually deleted
var len = op.content != null ? op.content.length : 1
var startId = op.id // You must not use op.id in the following loop, because op will change when deleted
// TODO: !! console.log('TODO: change this before commiting')
for (let i = 0; i < len; i++) {
var id = [startId[0], startId[1] + i]
var opIsDeleted = yield * transaction.isDeleted(id)
if (opIsDeleted) {
var delop = {
struct: 'Delete',
target: id
}
yield * this.tryExecute.call(transaction, delop)
}
}
}
}
}
whenTransactionsFinished () {
if (this.transactionInProgress) {
if (this.transactionsFinished == null) {
var resolve_
var promise = new Promise(function (resolve) {
resolve_ = resolve
})
this.transactionsFinished = {
resolve: resolve_,
promise: promise
}
}
return this.transactionsFinished.promise
} else {
return Promise.resolve()
}
}
// Check if there is another transaction request.
// * the last transaction is always a flush :)
getNextRequest () {
if (this.waitingTransactions.length === 0) {
if (this.transactionIsFlushed) {
this.transactionInProgress = false
this.transactionIsFlushed = false
if (this.transactionsFinished != null) {
this.transactionsFinished.resolve()
this.transactionsFinished = null
}
return null
} else {
this.transactionIsFlushed = true
return function * () {
yield * this.flush()
}
}
} else {
this.transactionIsFlushed = false
return this.waitingTransactions.shift()
}
}
requestTransaction (makeGen/* :any */, callImmediately) {
this.waitingTransactions.push(makeGen)
if (!this.transactionInProgress) {
this.transactionInProgress = true
setTimeout(() => {
this.transact(this.getNextRequest())
}, 0)
}
}
/*
Get a created/initialized type.
*/
getType (id) {
return this.initializedTypes[JSON.stringify(id)]
}
/*
Init type. This is called when a remote operation is retrieved, and transformed to a type
TODO: delete type from store.initializedTypes[id] when corresponding id was deleted!
*/
* initType (id, args) {
var sid = JSON.stringify(id)
var t = this.store.initializedTypes[sid]
if (t == null) {
var op/* :MapStruct | ListStruct */ = yield * this.getOperation(id)
if (op != null) {
t = yield * Y[op.type].typeDefinition.initType.call(this, this.store, op, args)
this.store.initializedTypes[sid] = t
}
}
return t
}
/*
Create type. This is called when the local user creates a type (which is a synchronous action)
*/
createType (typedefinition, id) {
var structname = typedefinition[0].struct
id = id || this.getNextOpId(1)
var op = Y.Struct[structname].create(id)
op.type = typedefinition[0].name
this.requestTransaction(function * () {
if (op.id[0] === '_') {
yield * this.setOperation(op)
} else {
yield * this.applyCreatedOperations([op])
}
})
var t = Y[op.type].typeDefinition.createType(this, op, typedefinition[1])
this.initializedTypes[JSON.stringify(op.id)] = t
return t
}
}
Y.AbstractDatabase = AbstractDatabase
}

View File

@@ -1,354 +0,0 @@
/* global async, databases, describe, beforeEach, afterEach */
/* eslint-env browser,jasmine,console */
'use strict'
var Y = require('./SpecHelper.js')
for (let database of databases) {
describe(`Database (${database})`, function () {
var store
describe('DeleteStore', function () {
describe('Basic', function () {
beforeEach(function () {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
})
afterEach(function (done) {
store.requestTransaction(function * () {
yield * this.store.destroy()
done()
})
})
it('Deleted operation is deleted', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['u1', 10], 1)
expect(yield * this.isDeleted(['u1', 10])).toBeTruthy()
expect(yield * this.getDeleteSet()).toEqual({'u1': [[10, 1, false]]})
done()
})
}))
it('Deleted operation extends other deleted operation', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['u1', 10], 1)
yield * this.markDeleted(['u1', 11], 1)
expect(yield * this.isDeleted(['u1', 10])).toBeTruthy()
expect(yield * this.isDeleted(['u1', 11])).toBeTruthy()
expect(yield * this.getDeleteSet()).toEqual({'u1': [[10, 2, false]]})
done()
})
}))
it('Deleted operation extends other deleted operation', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['0', 3], 1)
yield * this.markDeleted(['0', 4], 1)
yield * this.markDeleted(['0', 2], 1)
expect(yield * this.getDeleteSet()).toEqual({'0': [[2, 3, false]]})
done()
})
}))
it('Debug #1', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['166', 0], 1)
yield * this.markDeleted(['166', 2], 1)
yield * this.markDeleted(['166', 0], 1)
yield * this.markDeleted(['166', 2], 1)
yield * this.markGarbageCollected(['166', 2], 1)
yield * this.markDeleted(['166', 1], 1)
yield * this.markDeleted(['166', 3], 1)
yield * this.markGarbageCollected(['166', 3], 1)
yield * this.markDeleted(['166', 0], 1)
expect(yield * this.getDeleteSet()).toEqual({'166': [[0, 2, false], [2, 2, true]]})
done()
})
}))
it('Debug #2', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['293', 0], 1)
yield * this.markDeleted(['291', 2], 1)
yield * this.markDeleted(['291', 2], 1)
yield * this.markGarbageCollected(['293', 0], 1)
yield * this.markDeleted(['293', 1], 1)
yield * this.markGarbageCollected(['291', 2], 1)
expect(yield * this.getDeleteSet()).toEqual({'291': [[2, 1, true]], '293': [[0, 1, true], [1, 1, false]]})
done()
})
}))
it('Debug #3', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['581', 0], 1)
yield * this.markDeleted(['581', 1], 1)
yield * this.markDeleted(['580', 0], 1)
yield * this.markDeleted(['580', 0], 1)
yield * this.markGarbageCollected(['581', 0], 1)
yield * this.markDeleted(['581', 2], 1)
yield * this.markDeleted(['580', 1], 1)
yield * this.markDeleted(['580', 2], 1)
yield * this.markDeleted(['580', 1], 1)
yield * this.markDeleted(['580', 2], 1)
yield * this.markGarbageCollected(['581', 2], 1)
yield * this.markGarbageCollected(['581', 1], 1)
yield * this.markGarbageCollected(['580', 1], 1)
expect(yield * this.getDeleteSet()).toEqual({'580': [[0, 1, false], [1, 1, true], [2, 1, false]], '581': [[0, 3, true]]})
done()
})
}))
it('Debug #4', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['544', 0], 1)
yield * this.markDeleted(['543', 2], 1)
yield * this.markDeleted(['544', 0], 1)
yield * this.markDeleted(['543', 2], 1)
yield * this.markGarbageCollected(['544', 0], 1)
yield * this.markDeleted(['545', 1], 1)
yield * this.markDeleted(['543', 4], 1)
yield * this.markDeleted(['543', 3], 1)
yield * this.markDeleted(['544', 1], 1)
yield * this.markDeleted(['544', 2], 1)
yield * this.markDeleted(['544', 1], 1)
yield * this.markDeleted(['544', 2], 1)
yield * this.markGarbageCollected(['543', 2], 1)
yield * this.markGarbageCollected(['543', 4], 1)
yield * this.markGarbageCollected(['544', 2], 1)
yield * this.markGarbageCollected(['543', 3], 1)
expect(yield * this.getDeleteSet()).toEqual({'543': [[2, 3, true]], '544': [[0, 1, true], [1, 1, false], [2, 1, true]], '545': [[1, 1, false]]})
done()
})
}))
it('Debug #5', async(function * (done) {
store.requestTransaction(function * () {
yield * this.applyDeleteSet({'16': [[1, 2, false]], '17': [[0, 1, true], [1, 3, false]]})
expect(yield * this.getDeleteSet()).toEqual({'16': [[1, 2, false]], '17': [[0, 1, true], [1, 3, false]]})
yield * this.applyDeleteSet({'16': [[1, 2, false]], '17': [[0, 4, true]]})
expect(yield * this.getDeleteSet()).toEqual({'16': [[1, 2, false]], '17': [[0, 4, true]]})
done()
})
}))
it('Debug #6', async(function * (done) {
store.requestTransaction(function * () {
yield * this.applyDeleteSet({'40': [[0, 3, false]]})
expect(yield * this.getDeleteSet()).toEqual({'40': [[0, 3, false]]})
yield * this.applyDeleteSet({'39': [[2, 2, false]], '40': [[0, 1, true], [1, 2, false]], '41': [[2, 1, false]]})
expect(yield * this.getDeleteSet()).toEqual({'39': [[2, 2, false]], '40': [[0, 1, true], [1, 2, false]], '41': [[2, 1, false]]})
done()
})
}))
it('Debug #7', async(function * (done) {
store.requestTransaction(function * () {
yield * this.markDeleted(['9', 2], 1)
yield * this.markDeleted(['11', 2], 1)
yield * this.markDeleted(['11', 4], 1)
yield * this.markDeleted(['11', 1], 1)
yield * this.markDeleted(['9', 4], 1)
yield * this.markDeleted(['10', 0], 1)
yield * this.markGarbageCollected(['11', 2], 1)
yield * this.markDeleted(['11', 2], 1)
yield * this.markGarbageCollected(['11', 3], 1)
yield * this.markDeleted(['11', 3], 1)
yield * this.markDeleted(['11', 3], 1)
yield * this.markDeleted(['9', 4], 1)
yield * this.markDeleted(['10', 0], 1)
yield * this.markGarbageCollected(['11', 1], 1)
yield * this.markDeleted(['11', 1], 1)
expect(yield * this.getDeleteSet()).toEqual({'9': [[2, 1, false], [4, 1, false]], '10': [[0, 1, false]], '11': [[1, 3, true], [4, 1, false]]})
done()
})
}))
})
})
describe('OperationStore', function () {
describe('Basic Tests', function () {
beforeEach(function () {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
})
afterEach(function (done) {
store.requestTransaction(function * () {
yield * this.store.destroy()
done()
})
})
it('debug #1', function (done) {
store.requestTransaction(function * () {
yield * this.os.put({id: [2]})
yield * this.os.put({id: [0]})
yield * this.os.delete([2])
yield * this.os.put({id: [1]})
expect(yield * this.os.find([0])).toBeTruthy()
expect(yield * this.os.find([1])).toBeTruthy()
expect(yield * this.os.find([2])).toBeFalsy()
done()
})
})
it('can add&retrieve 5 elements', function (done) {
store.requestTransaction(function * () {
yield * this.os.put({val: 'four', id: [4]})
yield * this.os.put({val: 'one', id: [1]})
yield * this.os.put({val: 'three', id: [3]})
yield * this.os.put({val: 'two', id: [2]})
yield * this.os.put({val: 'five', id: [5]})
expect((yield * this.os.find([1])).val).toEqual('one')
expect((yield * this.os.find([2])).val).toEqual('two')
expect((yield * this.os.find([3])).val).toEqual('three')
expect((yield * this.os.find([4])).val).toEqual('four')
expect((yield * this.os.find([5])).val).toEqual('five')
done()
})
})
it('5 elements do not exist anymore after deleting them', function (done) {
store.requestTransaction(function * () {
yield * this.os.put({val: 'four', id: [4]})
yield * this.os.put({val: 'one', id: [1]})
yield * this.os.put({val: 'three', id: [3]})
yield * this.os.put({val: 'two', id: [2]})
yield * this.os.put({val: 'five', id: [5]})
yield * this.os.delete([4])
expect(yield * this.os.find([4])).not.toBeTruthy()
yield * this.os.delete([3])
expect(yield * this.os.find([3])).not.toBeTruthy()
yield * this.os.delete([2])
expect(yield * this.os.find([2])).not.toBeTruthy()
yield * this.os.delete([1])
expect(yield * this.os.find([1])).not.toBeTruthy()
yield * this.os.delete([5])
expect(yield * this.os.find([5])).not.toBeTruthy()
done()
})
})
})
var numberOfOSTests = 1000
describe(`Random Tests - after adding&deleting (0.8/0.2) ${numberOfOSTests} times`, function () {
var elements = []
beforeAll(function (done) {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
store.requestTransaction(function * () {
for (var i = 0; i < numberOfOSTests; i++) {
var r = Math.random()
if (r < 0.8) {
var obj = [Math.floor(Math.random() * numberOfOSTests * 10000)]
if (!(yield * this.os.find(obj))) {
elements.push(obj)
yield * this.os.put({id: obj})
}
} else if (elements.length > 0) {
var elemid = Math.floor(Math.random() * elements.length)
var elem = elements[elemid]
elements = elements.filter(function (e) {
return !Y.utils.compareIds(e, elem)
})
yield * this.os.delete(elem)
}
}
done()
})
})
afterAll(function (done) {
store.requestTransaction(function * () {
yield * this.store.destroy()
done()
})
})
it('can find every object', function (done) {
store.requestTransaction(function * () {
for (var id of elements) {
expect((yield * this.os.find(id)).id).toEqual(id)
}
done()
})
})
it('can find every object with lower bound search', function (done) {
store.requestTransaction(function * () {
for (var id of elements) {
var e = yield * this.os.findWithLowerBound(id)
expect(e.id).toEqual(id)
}
done()
})
})
it('iterating over a tree with lower bound yields the right amount of results', function (done) {
var lowerBound = elements[Math.floor(Math.random() * elements.length)]
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(lowerBound, e) || Y.utils.compareIds(e, lowerBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield * this.os.iterate(this, lowerBound, null, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree without bounds yield the right amount of results', function (done) {
var lowerBound = null
var expectedResults = elements.filter(function (e, pos) {
return elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield * this.os.iterate(this, lowerBound, null, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree with upper bound yields the right amount of results', function (done) {
var upperBound = elements[Math.floor(Math.random() * elements.length)]
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(e, upperBound) || Y.utils.compareIds(e, upperBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield * this.os.iterate(this, null, upperBound, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree with upper and lower bounds yield the right amount of results', function (done) {
var b1 = elements[Math.floor(Math.random() * elements.length)]
var b2 = elements[Math.floor(Math.random() * elements.length)]
var upperBound, lowerBound
if (Y.utils.smaller(b1, b2)) {
lowerBound = b1
upperBound = b2
} else {
lowerBound = b2
upperBound = b1
}
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(lowerBound, e) || Y.utils.compareIds(e, lowerBound)) &&
(Y.utils.smaller(e, upperBound) || Y.utils.compareIds(e, upperBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield * this.os.iterate(this, lowerBound, upperBound, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
})
})
})
}

View File

@@ -1,12 +0,0 @@
# Notes
### Terminology
* DB: DataBase that holds all the information of the shared object. It is devided into the OS, DS, and SS. This can be a persistent database or an in-memory database. Depending on the type of database, it could make sense to store OS, DS, and SS in different tables, or maybe different databases.
* OS: OperationStore holds all the operations. An operation is a js object with a fixed number of name fields.
* DS: DeleteStore holds the information about which operations are deleted and which operations were garbage collected (no longer available in the OS).
* SS: StateSet holds the current state of the OS. SS.getState(username) refers to the amount of operations that were received by that respective user.
* Op: Operation defines an action on a shared type. But it is also the format in which we store the model of a type. This is why it is also called a Struct/Structure.
* Type and Structure: We crearly distinguish between type and structure. Short explanation: A type (e.g. Strings, Numbers) have a number of functions that you can apply on them. (+) is well defined on both of them. They are *modeled* by a structure - the functions really change the structure of a type. Types can be implemented differently but still provide the same functionality. In Yjs, almost all types are realized as a doubly linked list (on which Yjs can provide eventual convergence)
*

View File

@@ -1,404 +0,0 @@
/* eslint-env browser, jasmine */
/*
This is just a compilation of functions that help to test this library!
*/
// When testing, you store everything on the global object. We call it g
var Y = require('./y.js')
require('../../y-memory/src/Memory.js')(Y)
require('../../y-array/src/Array.js')(Y)
require('../../y-map/src/Map.js')(Y)
require('../../y-indexeddb/src/IndexedDB.js')(Y)
module.exports = Y
var g
if (typeof global !== 'undefined') {
g = global
} else if (typeof window !== 'undefined') {
g = window
} else {
throw new Error('No global object?')
}
g.g = g
// Helper methods for the random number generator
Math.seedrandom = require('seedrandom')
g.generateRandomSeed = function generateRandomSeed () {
var seed
if (typeof window !== 'undefined' && window.location.hash.length > 1) {
seed = window.location.hash.slice(1) // first character is the hash!
console.warn('Using random seed that was specified in the url!')
} else {
seed = JSON.stringify(Math.random())
}
console.info('Using random seed: ' + seed)
g.setRandomSeed(seed)
}
g.setRandomSeed = function setRandomSeed (seed) {
Math.seedrandom.currentSeed = seed
Math.seedrandom(Math.seedrandom.currentSeed, { global: true })
}
g.generateRandomSeed()
g.YConcurrencyTestingMode = true
jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000
g.describeManyTimes = function describeManyTimes (times, name, f) {
for (var i = 0; i < times; i++) {
describe(name, f)
}
}
/*
Wait for a specified amount of time (in ms). defaults to 5ms
*/
function wait (t) {
if (t == null) {
t = 0
}
return new Promise(function (resolve) {
setTimeout(function () {
resolve()
}, t)
})
}
g.wait = wait
g.databases = ['memory']
if (typeof window !== 'undefined') {
g.databases.push('indexeddb')
} else {
g.databases.push('leveldb')
}
/*
returns a random element of o.
works on Object, and Array
*/
function getRandom (o) {
if (o instanceof Array) {
return o[Math.floor(Math.random() * o.length)]
} else if (o.constructor === Object) {
return o[getRandom(Object.keys(o))]
}
}
g.getRandom = getRandom
function getRandomNumber (n) {
if (n == null) {
n = 9999
}
return Math.floor(Math.random() * n)
}
g.getRandomNumber = getRandomNumber
function getRandomString () {
var chars = 'abcdefghijklmnopqrstuvwxyzäüöABCDEFGHIJKLMNOPQRSTUVWXYZÄÜÖ'
var char = chars[getRandomNumber(chars.length)] // ü\n\n\n\n\n\n\n'
var length = getRandomNumber(7)
var string = ''
for (var i = 0; i < length; i++) {
string += char
}
return string
}
g.getRandomString = getRandomString
function * applyTransactions (relAmount, numberOfTransactions, objects, users, transactions, noReconnect) {
g.generateRandomSeed() // create a new seed, so we can re-create the behavior
for (var i = 0; i < numberOfTransactions * relAmount + 1; i++) {
var r = Math.random()
if (r > 0.95) {
// 10% chance of toggling concurrent user interactions.
// There will be an artificial delay until ops can be executed by the type,
// therefore, operations of the database will be (pre)transformed until user operations arrive
yield (function simulateConcurrentUserInteractions (type) {
if (!(type instanceof Y.utils.CustomType) && type.y instanceof Y.utils.CustomType) {
// usually we expect type to be a custom type. But in YXml we share an object {y: YXml, dom: Dom} instead
type = type.y
}
if (type.eventHandler.awaiting === 0 && type.eventHandler._debuggingAwaiting !== true) {
type.eventHandler.awaiting = 1
type.eventHandler._debuggingAwaiting = true
} else {
// fixAwaitingInType will handle _debuggingAwaiting
return fixAwaitingInType(type)
}
})(getRandom(objects))
} else if (r >= 0.5) {
// 40% chance to flush
yield Y.utils.globalRoom.flushOne() // flushes for some user.. (not necessarily 0)
} else if (noReconnect || r >= 0.05) {
// 45% chance to create operation
var done = getRandom(transactions)(getRandom(objects))
if (done != null) {
yield done
} else {
yield wait()
}
yield Y.utils.globalRoom.whenTransactionsFinished()
} else {
// 5% chance to disconnect/reconnect
var u = getRandom(users)
yield Promise.all(objects.map(fixAwaitingInType))
if (u.connector.isDisconnected()) {
yield u.reconnect()
} else {
yield u.disconnect()
}
yield Promise.all(objects.map(fixAwaitingInType))
}
}
}
function fixAwaitingInType (type) {
if (!(type instanceof Y.utils.CustomType) && type.y instanceof Y.utils.CustomType) {
// usually we expect type to be a custom type. But in YXml we share an object {y: YXml, dom: Dom} instead
type = type.y
}
return new Promise(function (resolve) {
type.os.whenTransactionsFinished().then(function () {
// _debuggingAwaiting artificially increases the awaiting property. We need to make sure that we only do that once / reverse the effect once
type.os.requestTransaction(function * () {
if (type.eventHandler.awaiting > 0 && type.eventHandler._debuggingAwaiting === true) {
type.eventHandler._debuggingAwaiting = false
yield * type.eventHandler.awaitOps(this, function * () { /* mock function */ })
}
wait(50).then(type.os.whenTransactionsFinished()).then(wait(50)).then(resolve)
})
})
})
}
g.fixAwaitingInType = fixAwaitingInType
g.applyRandomTransactionsNoGCNoDisconnect = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield * applyTransactions(1, numberOfTransactions, objects, users, transactions, true)
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
})
g.applyRandomTransactionsAllRejoinNoGC = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield * applyTransactions(1, numberOfTransactions, objects, users, transactions)
yield Promise.all(objects.map(fixAwaitingInType))
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
for (var u in users) {
yield Promise.all(objects.map(fixAwaitingInType))
yield users[u].reconnect()
yield Promise.all(objects.map(fixAwaitingInType))
}
yield Promise.all(objects.map(fixAwaitingInType))
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
yield g.garbageCollectAllUsers(users)
})
g.applyRandomTransactionsWithGC = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield * applyTransactions(1, numberOfTransactions, objects, users.slice(1), transactions)
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
for (var u in users) {
// TODO: here, we enforce that two users never sync at the same time with u[0]
// enforce that in the connector itself!
yield users[u].reconnect()
}
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
yield g.garbageCollectAllUsers(users)
})
g.garbageCollectAllUsers = async(function * garbageCollectAllUsers (users) {
yield Y.utils.globalRoom.flushAll()
for (var i in users) {
yield users[i].db.emptyGarbageCollector()
}
})
g.compareAllUsers = async(function * compareAllUsers (users) {
var s1, s2 // state sets
var ds1, ds2 // delete sets
var allDels1, allDels2 // all deletions
var db1 = [] // operation store of user1
yield Y.utils.globalRoom.flushAll()
yield g.garbageCollectAllUsers(users)
yield Y.utils.globalRoom.flushAll()
// disconnect, then reconnect all users
// We do this to make sure that the gc is updated by everyone
for (var i = 0; i < users.length; i++) {
yield users[i].disconnect()
yield wait()
yield users[i].reconnect()
}
yield wait()
yield Y.utils.globalRoom.flushAll()
// t1 and t2 basically do the same. They define t[1,2], ds[1,2], and allDels[1,2]
function * t1 () {
s1 = yield * this.getStateSet()
ds1 = yield * this.getDeleteSet()
allDels1 = []
yield * this.ds.iterate(this, null, null, function * (d) {
allDels1.push(d)
})
}
function * t2 () {
s2 = yield * this.getStateSet()
ds2 = yield * this.getDeleteSet()
allDels2 = []
yield * this.ds.iterate(this, null, null, function * (d) {
allDels2.push(d)
})
}
var buffer = Y.utils.globalRoom.buffers
for (var name in buffer) {
if (buffer[name].length > 0) {
// not all ops were transmitted..
debugger // eslint-disable-line
}
}
for (var uid = 0; uid < users.length; uid++) {
var u = users[uid]
u.db.requestTransaction(function * () {
var sv = yield * this.getStateVector()
for (var s of sv) {
yield * this.updateState(s.user)
}
// compare deleted ops against deleteStore
yield * this.os.iterate(this, null, null, function * (o) {
if (o.deleted === true) {
expect(yield * this.isDeleted(o.id)).toBeTruthy()
}
})
// compare deleteStore against deleted ops
var ds = []
yield * this.ds.iterate(this, null, null, function * (d) {
ds.push(d)
})
for (var j in ds) {
var d = ds[j]
for (var i = 0; i < d.len; i++) {
var o = yield * this.getInsertion([d.id[0], d.id[1] + i])
// gc'd or deleted
if (d.gc) {
expect(o).toBeFalsy()
} else {
expect(o.deleted).toBeTruthy()
}
}
}
})
// compare allDels tree
if (s1 == null) {
u.db.requestTransaction(function * () {
yield * t1.call(this)
yield * this.os.iterate(this, null, null, function * (o) {
o = Y.utils.copyObject(o)
delete o.origin
delete o.originOf
db1.push(o)
})
})
} else {
u.db.requestTransaction(function * () {
yield * t2.call(this)
var db2 = []
yield * this.os.iterate(this, null, null, function * (o) {
o = Y.utils.copyObject(o)
delete o.origin
delete o.originOf
db2.push(o)
})
expect(s1).toEqual(s2)
expect(allDels1).toEqual(allDels2) // inner structure
expect(ds1).toEqual(ds2) // exported structure
db2.forEach((o, i) => {
expect(db1[i]).toEqual(o)
})
})
}
yield u.db.whenTransactionsFinished()
}
})
g.createUsers = async(function * createUsers (self, numberOfUsers, database, initType) {
if (Y.utils.globalRoom.users[0] != null) {
yield Y.utils.globalRoom.flushAll()
}
// destroy old users
for (var u in Y.utils.globalRoom.users) {
Y.utils.globalRoom.users[u].y.destroy()
}
self.users = null
var promises = []
for (var i = 0; i < numberOfUsers; i++) {
promises.push(Y({
db: {
name: database,
namespace: 'User ' + i,
cleanStart: true,
gcTimeout: -1,
gc: true,
repairCheckInterval: -1
},
connector: {
name: 'Test',
debug: false
},
share: {
root: initType || 'Map'
}
}))
}
self.users = yield Promise.all(promises)
self.types = self.users.map(function (u) { return u.share.root })
return self.users
})
/*
Until async/await arrives in js, we use this function to wait for promises
by yielding them.
*/
function async (makeGenerator) {
return function (arg) {
var generator = makeGenerator.apply(this, arguments)
function handle (result) {
if (result.done) return Promise.resolve(result.value)
return Promise.resolve(result.value).then(function (res) {
return handle(generator.next(res))
}, function (err) {
return handle(generator.throw(err))
})
}
try {
return handle(generator.next())
} catch (ex) {
generator.throw(ex)
// return Promise.reject(ex)
}
}
}
g.async = async
function logUsers (self) {
if (self.constructor === Array) {
self = {users: self}
}
self.users[0].db.logTable()
self.users[1].db.logTable()
self.users[2].db.logTable()
}
g.logUsers = logUsers

View File

@@ -1,414 +0,0 @@
/* @flow */
'use strict'
/*
An operation also defines the structure of a type. This is why operation and
structure are used interchangeably here.
It must be of the type Object. I hope to achieve some performance
improvements when working on databases that support the json format.
An operation must have the following properties:
* encode
- Encode the structure in a readable format (preferably string- todo)
* decode (todo)
- decode structure to json
* execute
- Execute the semantics of an operation.
* requiredOps
- Operations that are required to execute this operation.
*/
export default function extendStruct (Y) {
var Struct = {
/* This is the only operation that is actually not a structure, because
it is not stored in the OS. This is why it _does not_ have an id
op = {
target: Id
}
*/
Delete: {
encode: function (op) {
return {
target: op.target,
length: op.length || 0,
struct: 'Delete'
}
},
requiredOps: function (op) {
return [] // [op.target]
},
execute: function * (op) {
return yield * this.deleteOperation(op.target, op.length || 1)
}
},
Insert: {
/* {
content: [any],
opContent: Id,
id: Id,
left: Id,
origin: Id,
right: Id,
parent: Id,
parentSub: string (optional), // child of Map type
}
*/
encode: function (op/* :Insertion */) /* :Insertion */ {
// TODO: you could not send the "left" property, then you also have to
// "op.left = null" in $execute or $decode
var e/* :any */ = {
id: op.id,
left: op.left,
right: op.right,
origin: op.origin,
parent: op.parent,
struct: op.struct
}
if (op.parentSub != null) {
e.parentSub = op.parentSub
}
if (op.hasOwnProperty('opContent')) {
e.opContent = op.opContent
} else {
e.content = op.content.slice()
}
return e
},
requiredOps: function (op) {
var ids = []
if (op.left != null) {
ids.push(op.left)
}
if (op.right != null) {
ids.push(op.right)
}
if (op.origin != null && !Y.utils.compareIds(op.left, op.origin)) {
ids.push(op.origin)
}
// if (op.right == null && op.left == null) {
ids.push(op.parent)
if (op.opContent != null) {
ids.push(op.opContent)
}
return ids
},
getDistanceToOrigin: function * (op) {
if (op.left == null) {
return 0
} else {
var d = 0
var o = yield * this.getInsertion(op.left)
while (!Y.utils.matchesId(o, op.origin)) {
d++
if (o.left == null) {
break
} else {
o = yield * this.getInsertion(o.left)
}
}
return d
}
},
/*
# $this has to find a unique position between origin and the next known character
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right
# let $OL= [o1,o2,o3,o4], whereby $this is to be inserted between o1 and o4
# o2,o3 and o4 origin is 1 (the position of o2)
# there is the case that $this.creator < o2.creator, but o3.creator < $this.creator
# then o2 knows o3. Since on another client $OL could be [o1,o3,o4] the problem is complex
# therefore $this would be always to the right of o3
# case 2: $origin < $o.origin
# if current $this insert_position > $o origin: $this ins
# else $insert_position will not change
# (maybe we encounter case 1 later, then this will be to the right of $o)
# case 3: $origin > $o.origin
# $this insert_position is to the left of $o (forever!)
*/
execute: function * (op) {
var i // loop counter
// during this function some ops may get split into two pieces (e.g. with getInsertionCleanEnd)
// We try to merge them later, if possible
var tryToRemergeLater = []
if (op.origin != null) { // TODO: !== instead of !=
// we save in origin that op originates in it
// we need that later when we eventually garbage collect origin (see transaction)
var origin = yield * this.getInsertionCleanEnd(op.origin)
if (origin.originOf == null) {
origin.originOf = []
}
origin.originOf.push(op.id)
yield * this.setOperation(origin)
if (origin.right != null) {
tryToRemergeLater.push(origin.right)
}
}
var distanceToOrigin = i = yield * Struct.Insert.getDistanceToOrigin.call(this, op) // most cases: 0 (starts from 0)
// now we begin to insert op in the list of insertions..
var o
var parent
var start
// find o. o is the first conflicting operation
if (op.left != null) {
o = yield * this.getInsertionCleanEnd(op.left)
if (!Y.utils.compareIds(op.left, op.origin) && o.right != null) {
// only if not added previously
tryToRemergeLater.push(o.right)
}
o = (o.right == null) ? null : yield * this.getOperation(o.right)
} else { // left == null
parent = yield * this.getOperation(op.parent)
let startId = op.parentSub ? parent.map[op.parentSub] : parent.start
start = startId == null ? null : yield * this.getOperation(startId)
o = start
}
// make sure to split op.right if necessary (also add to tryCombineWithLeft)
if (op.right != null) {
tryToRemergeLater.push(op.right)
yield * this.getInsertionCleanStart(op.right)
}
// handle conflicts
while (true) {
if (o != null && !Y.utils.compareIds(o.id, op.right)) {
var oOriginDistance = yield * Struct.Insert.getDistanceToOrigin.call(this, o)
if (oOriginDistance === i) {
// case 1
if (o.id[0] < op.id[0]) {
op.left = Y.utils.getLastId(o)
distanceToOrigin = i + 1 // just ignore o.content.length, doesn't make a difference
}
} else if (oOriginDistance < i) {
// case 2
if (i - distanceToOrigin <= oOriginDistance) {
op.left = Y.utils.getLastId(o)
distanceToOrigin = i + 1 // just ignore o.content.length, doesn't make a difference
}
} else {
break
}
i++
if (o.right != null) {
o = yield * this.getInsertion(o.right)
} else {
o = null
}
} else {
break
}
}
// reconnect..
var left = null
var right = null
if (parent == null) {
parent = yield * this.getOperation(op.parent)
}
// reconnect left and set right of op
if (op.left != null) {
left = yield * this.getInsertion(op.left)
// link left
op.right = left.right
left.right = op.id
yield * this.setOperation(left)
} else {
// set op.right from parent, if necessary
op.right = op.parentSub ? parent.map[op.parentSub] || null : parent.start
}
// reconnect right
if (op.right != null) {
// TODO: wanna connect right too?
right = yield * this.getOperation(op.right)
right.left = Y.utils.getLastId(op)
// if right exists, and it is supposed to be gc'd. Remove it from the gc
if (right.gc != null) {
if (right.content != null && right.content.length > 1) {
right = yield * this.getInsertionCleanEnd(right.id)
}
this.store.removeFromGarbageCollector(right)
}
yield * this.setOperation(right)
}
// update parents .map/start/end properties
if (op.parentSub != null) {
if (left == null) {
parent.map[op.parentSub] = op.id
yield * this.setOperation(parent)
}
// is a child of a map struct.
// Then also make sure that only the most left element is not deleted
// We do not call the type in this case (this is what the third parameter is for)
if (op.right != null) {
yield * this.deleteOperation(op.right, 1, true)
}
if (op.left != null) {
yield * this.deleteOperation(op.id, 1, true)
}
} else {
if (right == null || left == null) {
if (right == null) {
parent.end = Y.utils.getLastId(op)
}
if (left == null) {
parent.start = op.id
}
yield * this.setOperation(parent)
}
}
// try to merge original op.left and op.origin
for (i = 0; i < tryToRemergeLater.length; i++) {
var m = yield * this.getOperation(tryToRemergeLater[i])
yield * this.tryCombineWithLeft(m)
}
}
},
List: {
/*
{
start: null,
end: null,
struct: "List",
type: "",
id: this.os.getNextOpId(1)
}
*/
create: function (id) {
return {
start: null,
end: null,
struct: 'List',
id: id
}
},
encode: function (op) {
var e = {
struct: 'List',
id: op.id,
type: op.type
}
if (op.requires != null) {
e.requires = op.requires
}
if (op.info != null) {
e.info = op.info
}
return e
},
requiredOps: function () {
/*
var ids = []
if (op.start != null) {
ids.push(op.start)
}
if (op.end != null){
ids.push(op.end)
}
return ids
*/
return []
},
execute: function * (op) {
op.start = null
op.end = null
},
ref: function * (op, pos) {
if (op.start == null) {
return null
}
var res = null
var o = yield * this.getOperation(op.start)
while (true) {
if (!o.deleted) {
res = o
pos--
}
if (pos >= 0 && o.right != null) {
o = yield * this.getOperation(o.right)
} else {
break
}
}
return res
},
map: function * (o, f) {
o = o.start
var res = []
while (o != null) { // TODO: change to != (at least some convention)
var operation = yield * this.getOperation(o)
if (!operation.deleted) {
res.push(f(operation))
}
o = operation.right
}
return res
}
},
Map: {
/*
{
map: {},
struct: "Map",
type: "",
id: this.os.getNextOpId(1)
}
*/
create: function (id) {
return {
id: id,
map: {},
struct: 'Map'
}
},
encode: function (op) {
var e = {
struct: 'Map',
type: op.type,
id: op.id,
map: {} // overwrite map!!
}
if (op.requires != null) {
e.requires = op.requires
}
if (op.info != null) {
e.info = op.info
}
return e
},
requiredOps: function () {
return []
},
execute: function * () {},
/*
Get a property by name
*/
get: function * (op, name) {
var oid = op.map[name]
if (oid != null) {
var res = yield * this.getOperation(oid)
if (res == null || res.deleted) {
return void 0
} else if (res.opContent == null) {
return res.content[0]
} else {
return yield * this.getType(res.opContent)
}
}
}
}
}
Y.Struct = Struct
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,793 +0,0 @@
/*
EventHandler is an helper class for constructing custom types.
Why: When constructing custom types, you sometimes want your types to work
synchronous: E.g.
``` Synchronous
mytype.setSomething("yay")
mytype.getSomething() === "yay"
```
versus
``` Asynchronous
mytype.setSomething("yay")
mytype.getSomething() === undefined
mytype.waitForSomething().then(function(){
mytype.getSomething() === "yay"
})
```
The structures usually work asynchronously (you have to wait for the
database request to finish). EventHandler helps you to make your type
synchronous.
*/
export default function Utils (Y) {
Y.utils = {}
Y.utils.bubbleEvent = function (type, event) {
type.eventHandler.callEventListeners(event)
event.path = []
while (type != null && type._deepEventHandler != null) {
type._deepEventHandler.callEventListeners(event)
var parent = null
if (type._parent != null) {
parent = type.os.getType(type._parent)
}
if (parent != null && parent._getPathToChild != null) {
event.path = [parent._getPathToChild(type._model)].concat(event.path)
type = parent
} else {
type = null
}
}
}
class EventListenerHandler {
constructor () {
this.eventListeners = []
}
destroy () {
this.eventListeners = null
}
/*
Basic event listener boilerplate...
*/
addEventListener (f) {
this.eventListeners.push(f)
}
removeEventListener (f) {
this.eventListeners = this.eventListeners.filter(function (g) {
return f !== g
})
}
removeAllEventListeners () {
this.eventListeners = []
}
callEventListeners (event) {
for (var i = 0; i < this.eventListeners.length; i++) {
try {
var _event = {}
for (var name in event) {
_event[name] = event[name]
}
this.eventListeners[i](_event)
} catch (e) {
console.error('Your observer threw an error. This error was caught so that Yjs still can ensure data consistency! In order to debug this error you have to check "Pause On Caught Exceptions"', e)
}
}
}
}
Y.utils.EventListenerHandler = EventListenerHandler
class EventHandler extends EventListenerHandler {
/* ::
waiting: Array<Insertion | Deletion>;
awaiting: number;
onevent: Function;
eventListeners: Array<Function>;
*/
/*
onevent: is called when the structure changes.
Note: "awaiting opertations" is used to denote operations that were
prematurely called. Events for received operations can not be executed until
all prematurely called operations were executed ("waiting operations")
*/
constructor (onevent /* : Function */) {
super()
this.waiting = []
this.awaiting = 0
this.onevent = onevent
}
destroy () {
super.destroy()
this.waiting = null
this.onevent = null
}
/*
Call this when a new operation arrives. It will be executed right away if
there are no waiting operations, that you prematurely executed
*/
receivedOp (op) {
if (this.awaiting <= 0) {
this.onevent(op)
} else if (op.struct === 'Delete') {
var self = this
var checkDelete = function checkDelete (d) {
if (d.length == null) {
throw new Error('This shouldn\'t happen! d.length must be defined!')
}
// we check if o deletes something in self.waiting
// if so, we remove the deleted operation
for (var w = 0; w < self.waiting.length; w++) {
var i = self.waiting[w]
if (i.struct === 'Insert' && i.id[0] === d.target[0]) {
var iLength = i.hasOwnProperty('content') ? i.content.length : 1
var dStart = d.target[1]
var dEnd = d.target[1] + (d.length || 1)
var iStart = i.id[1]
var iEnd = i.id[1] + iLength
// Check if they don't overlap
if (iEnd <= dStart || dEnd <= iStart) {
// no overlapping
continue
}
// we check all overlapping cases. All cases:
/*
1) iiiii
ddddd
--> modify i and d
2) iiiiiii
ddddd
--> modify i, remove d
3) iiiiiii
ddd
--> remove d, modify i, and create another i (for the right hand side)
4) iiiii
ddddddd
--> remove i, modify d
5) iiiiiii
ddddddd
--> remove both i and d (**)
6) iiiiiii
ddddd
--> modify i, remove d
7) iii
ddddddd
--> remove i, create and apply two d with checkDelete(d) (**)
8) iiiii
ddddddd
--> remove i, modify d (**)
9) iiiii
ddddd
--> modify i and d
(**) (also check if i contains content or type)
*/
// TODO: I left some debugger statements, because I want to debug all cases once in production. REMEMBER END TODO
if (iStart < dStart) {
if (dStart < iEnd) {
if (iEnd < dEnd) {
// Case 1
// remove the right part of i's content
i.content.splice(dStart - iStart)
// remove the start of d's deletion
d.length = dEnd - iEnd
d.target = [d.target[0], iEnd]
continue
} else if (iEnd === dEnd) {
// Case 2
i.content.splice(dStart - iStart)
// remove d, we do that by simply ending this function
return
} else { // (dEnd < iEnd)
// Case 3
var newI = {
id: [i.id[0], dEnd],
content: i.content.slice(dEnd - iStart),
struct: 'Insert'
}
self.waiting.push(newI)
i.content.splice(dStart - iStart)
return
}
}
} else if (dStart === iStart) {
if (iEnd < dEnd) {
// Case 4
d.length = dEnd - iEnd
d.target = [d.target[0], iEnd]
i.content = []
continue
} else if (iEnd === dEnd) {
// Case 5
self.waiting.splice(w, 1)
return
} else { // (dEnd < iEnd)
// Case 6
i.content = i.content.slice(dEnd - iStart)
i.id = [i.id[0], dEnd]
return
}
} else { // (dStart < iStart)
if (iStart < dEnd) {
// they overlap
/*
7) iii
ddddddd
--> remove i, create and apply two d with checkDelete(d) (**)
8) iiiii
ddddddd
--> remove i, modify d (**)
9) iiiii
ddddd
--> modify i and d
*/
if (iEnd < dEnd) {
// Case 7
// debugger // TODO: You did not test this case yet!!!! (add the debugger here)
self.waiting.splice(w, 1)
checkDelete({
target: [d.target[0], dStart],
length: iStart - dStart,
struct: 'Delete'
})
checkDelete({
target: [d.target[0], iEnd],
length: iEnd - dEnd,
struct: 'Delete'
})
return
} else if (iEnd === dEnd) {
// Case 8
self.waiting.splice(w, 1)
w--
d.length -= iLength
continue
} else { // dEnd < iEnd
// Case 9
d.length = iStart - dStart
i.content.splice(0, dEnd - iStart)
i.id = [i.id[0], dEnd]
continue
}
}
}
}
}
// finished with remaining operations
self.waiting.push(d)
}
if (op.key == null) {
// deletes in list
checkDelete(op)
} else {
// deletes in map
this.waiting.push(op)
}
} else {
this.waiting.push(op)
}
}
/*
You created some operations, and you want the `onevent` function to be
called right away. Received operations will not be executed untill all
prematurely called operations are executed
*/
awaitAndPrematurelyCall (ops) {
this.awaiting++
ops.map(Y.utils.copyOperation).forEach(this.onevent)
}
* awaitOps (transaction, f, args) {
function notSoSmartSort (array) {
// this function sorts insertions in a executable order
var result = []
while (array.length > 0) {
for (var i = 0; i < array.length; i++) {
var independent = true
for (var j = 0; j < array.length; j++) {
if (Y.utils.matchesId(array[j], array[i].left)) {
// array[i] depends on array[j]
independent = false
break
}
}
if (independent) {
result.push(array.splice(i, 1)[0])
i--
}
}
}
return result
}
var before = this.waiting.length
// somehow create new operations
yield * f.apply(transaction, args)
// remove all appended ops / awaited ops
this.waiting.splice(before)
if (this.awaiting > 0) this.awaiting--
// if there are no awaited ops anymore, we can update all waiting ops, and send execute them (if there are still no awaited ops)
if (this.awaiting === 0 && this.waiting.length > 0) {
// update all waiting ops
for (let i = 0; i < this.waiting.length; i++) {
var o = this.waiting[i]
if (o.struct === 'Insert') {
var _o = yield * transaction.getInsertion(o.id)
if (_o.parentSub != null && _o.left != null) {
// if o is an insertion of a map struc (parentSub is defined), then it shouldn't be necessary to compute left
this.waiting.splice(i, 1)
i-- // update index
} else if (!Y.utils.compareIds(_o.id, o.id)) {
// o got extended
o.left = [o.id[0], o.id[1] - 1]
} else if (_o.left == null) {
o.left = null
} else {
// find next undeleted op
var left = yield * transaction.getInsertion(_o.left)
while (left.deleted != null) {
if (left.left != null) {
left = yield * transaction.getInsertion(left.left)
} else {
left = null
break
}
}
o.left = left != null ? Y.utils.getLastId(left) : null
}
}
}
// the previous stuff was async, so we have to check again!
// We also pull changes from the bindings, if there exists such a method, this could increase awaiting too
if (this._pullChanges != null) {
this._pullChanges()
}
if (this.awaiting === 0) {
// sort by type, execute inserts first
var ins = []
var dels = []
this.waiting.forEach(function (o) {
if (o.struct === 'Delete') {
dels.push(o)
} else {
ins.push(o)
}
})
this.waiting = []
// put in executable order
ins = notSoSmartSort(ins)
// this.onevent can trigger the creation of another operation
// -> check if this.awaiting increased & stop computation if it does
for (var i = 0; i < ins.length; i++) {
if (this.awaiting === 0) {
this.onevent(ins[i])
} else {
this.waiting = this.waiting.concat(ins.slice(i))
break
}
}
for (i = 0; i < dels.length; i++) {
if (this.awaiting === 0) {
this.onevent(dels[i])
} else {
this.waiting = this.waiting.concat(dels.slice(i))
break
}
}
}
}
}
// TODO: Remove awaitedInserts and awaitedDeletes in favor of awaitedOps, as they are deprecated and do not always work
// Do this in one of the coming releases that are breaking anyway
/*
Call this when you successfully awaited the execution of n Insert operations
*/
awaitedInserts (n) {
var ops = this.waiting.splice(this.waiting.length - n)
for (var oid = 0; oid < ops.length; oid++) {
var op = ops[oid]
if (op.struct === 'Insert') {
for (var i = this.waiting.length - 1; i >= 0; i--) {
let w = this.waiting[i]
// TODO: do I handle split operations correctly here? Super unlikely, but yeah..
// Also: can this case happen? Can op be inserted in the middle of a larger op that is in $waiting?
if (w.struct === 'Insert') {
if (Y.utils.matchesId(w, op.left)) {
// include the effect of op in w
w.right = op.id
// exclude the effect of w in op
op.left = w.left
} else if (Y.utils.compareIds(w.id, op.right)) {
// similar..
w.left = Y.utils.getLastId(op)
op.right = w.right
}
}
}
} else {
throw new Error('Expected Insert Operation!')
}
}
this._tryCallEvents(n)
}
/*
Call this when you successfully awaited the execution of n Delete operations
*/
awaitedDeletes (n, newLeft) {
var ops = this.waiting.splice(this.waiting.length - n)
for (var j = 0; j < ops.length; j++) {
var del = ops[j]
if (del.struct === 'Delete') {
if (newLeft != null) {
for (var i = 0; i < this.waiting.length; i++) {
let w = this.waiting[i]
// We will just care about w.left
if (w.struct === 'Insert' && Y.utils.compareIds(del.target, w.left)) {
w.left = newLeft
}
}
}
} else {
throw new Error('Expected Delete Operation!')
}
}
this._tryCallEvents(n)
}
/* (private)
Try to execute the events for the waiting operations
*/
_tryCallEvents () {
function notSoSmartSort (array) {
var result = []
while (array.length > 0) {
for (var i = 0; i < array.length; i++) {
var independent = true
for (var j = 0; j < array.length; j++) {
if (Y.utils.matchesId(array[j], array[i].left)) {
// array[i] depends on array[j]
independent = false
break
}
}
if (independent) {
result.push(array.splice(i, 1)[0])
i--
}
}
}
return result
}
if (this.awaiting > 0) this.awaiting--
if (this.awaiting === 0 && this.waiting.length > 0) {
var ins = []
var dels = []
this.waiting.forEach(function (o) {
if (o.struct === 'Delete') {
dels.push(o)
} else {
ins.push(o)
}
})
ins = notSoSmartSort(ins)
ins.forEach(this.onevent)
dels.forEach(this.onevent)
this.waiting = []
}
}
}
Y.utils.EventHandler = EventHandler
/*
Default class of custom types!
*/
class CustomType {
getPath () {
var parent = null
if (this._parent != null) {
parent = this.os.getType(this._parent)
}
if (parent != null && parent._getPathToChild != null) {
var firstKey = parent._getPathToChild(this._model)
var parentKeys = parent.getPath()
parentKeys.push(firstKey)
return parentKeys
} else {
return []
}
}
}
Y.utils.CustomType = CustomType
/*
A wrapper for the definition of a custom type.
Every custom type must have three properties:
* struct
- Structname of this type
* initType
- Given a model, creates a custom type
* class
- the constructor of the custom type (e.g. in order to inherit from a type)
*/
class CustomTypeDefinition { // eslint-disable-line
/* ::
struct: any;
initType: any;
class: Function;
name: String;
*/
constructor (def) {
if (def.struct == null ||
def.initType == null ||
def.class == null ||
def.name == null ||
def.createType == null
) {
throw new Error('Custom type was not initialized correctly!')
}
this.struct = def.struct
this.initType = def.initType
this.createType = def.createType
this.class = def.class
this.name = def.name
if (def.appendAdditionalInfo != null) {
this.appendAdditionalInfo = def.appendAdditionalInfo
}
this.parseArguments = (def.parseArguments || function () {
return [this]
}).bind(this)
this.parseArguments.typeDefinition = this
}
}
Y.utils.CustomTypeDefinition = CustomTypeDefinition
Y.utils.isTypeDefinition = function isTypeDefinition (v) {
if (v != null) {
if (v instanceof Y.utils.CustomTypeDefinition) return [v]
else if (v.constructor === Array && v[0] instanceof Y.utils.CustomTypeDefinition) return v
else if (v instanceof Function && v.typeDefinition instanceof Y.utils.CustomTypeDefinition) return [v.typeDefinition]
}
return false
}
/*
Make a flat copy of an object
(just copy properties)
*/
function copyObject (o) {
var c = {}
for (var key in o) {
c[key] = o[key]
}
return c
}
Y.utils.copyObject = copyObject
/*
Copy an operation, so that it can be manipulated.
Note: You must not change subproperties (except o.content)!
*/
function copyOperation (o) {
o = copyObject(o)
if (o.content != null) {
o.content = o.content.map(function (c) { return c })
}
return o
}
Y.utils.copyOperation = copyOperation
/*
Defines a smaller relation on Id's
*/
function smaller (a, b) {
return a[0] < b[0] || (a[0] === b[0] && (a[1] < b[1] || typeof a[1] < typeof b[1]))
}
Y.utils.smaller = smaller
function inDeletionRange (del, ins) {
return del.target[0] === ins[0] && del.target[1] <= ins[1] && ins[1] < del.target[1] + (del.length || 1)
}
Y.utils.inDeletionRange = inDeletionRange
function compareIds (id1, id2) {
if (id1 == null || id2 == null) {
return id1 === id2
} else {
return id1[0] === id2[0] && id1[1] === id2[1]
}
}
Y.utils.compareIds = compareIds
function matchesId (op, id) {
if (id == null || op == null) {
return id === op
} else {
if (id[0] === op.id[0]) {
if (op.content == null) {
return id[1] === op.id[1]
} else {
return id[1] >= op.id[1] && id[1] < op.id[1] + op.content.length
}
}
}
}
Y.utils.matchesId = matchesId
function getLastId (op) {
if (op.content == null || op.content.length === 1) {
return op.id
} else {
return [op.id[0], op.id[1] + op.content.length - 1]
}
}
Y.utils.getLastId = getLastId
function createEmptyOpsArray (n) {
var a = new Array(n)
for (var i = 0; i < a.length; i++) {
a[i] = {
id: [null, null]
}
}
return a
}
function createSmallLookupBuffer (Store) {
/*
This buffer implements a very small buffer that temporarily stores operations
after they are read / before they are written.
The buffer basically implements FIFO. Often requested lookups will be re-queued every time they are looked up / written.
It can speed up lookups on Operation Stores and State Stores. But it does not require notable use of memory or processing power.
Good for os and ss, bot not for ds (because it often uses methods that require a flush)
I tried to optimize this for performance, therefore no highlevel operations.
*/
class SmallLookupBuffer extends Store {
constructor (arg1, arg2) {
// super(...arguments) -- do this when this is supported by stable nodejs
super(arg1, arg2)
this.writeBuffer = createEmptyOpsArray(5)
this.readBuffer = createEmptyOpsArray(10)
}
* find (id, noSuperCall) {
var i, r
for (i = this.readBuffer.length - 1; i >= 0; i--) {
r = this.readBuffer[i]
// we don't have to use compareids, because id is always defined!
if (r.id[1] === id[1] && r.id[0] === id[0]) {
// found r
// move r to the end of readBuffer
for (; i < this.readBuffer.length - 1; i++) {
this.readBuffer[i] = this.readBuffer[i + 1]
}
this.readBuffer[this.readBuffer.length - 1] = r
return r
}
}
var o
for (i = this.writeBuffer.length - 1; i >= 0; i--) {
r = this.writeBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
o = r
break
}
}
if (i < 0 && noSuperCall === undefined) {
// did not reach break in last loop
// read id and put it to the end of readBuffer
o = yield * super.find(id)
}
if (o != null) {
for (i = 0; i < this.readBuffer.length - 1; i++) {
this.readBuffer[i] = this.readBuffer[i + 1]
}
this.readBuffer[this.readBuffer.length - 1] = o
}
return o
}
* put (o) {
var id = o.id
var i, r // helper variables
for (i = this.writeBuffer.length - 1; i >= 0; i--) {
r = this.writeBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
// is already in buffer
// forget r, and move o to the end of writeBuffer
for (; i < this.writeBuffer.length - 1; i++) {
this.writeBuffer[i] = this.writeBuffer[i + 1]
}
this.writeBuffer[this.writeBuffer.length - 1] = o
break
}
}
if (i < 0) {
// did not reach break in last loop
// write writeBuffer[0]
var write = this.writeBuffer[0]
if (write.id[0] !== null) {
yield * super.put(write)
}
// put o to the end of writeBuffer
for (i = 0; i < this.writeBuffer.length - 1; i++) {
this.writeBuffer[i] = this.writeBuffer[i + 1]
}
this.writeBuffer[this.writeBuffer.length - 1] = o
}
// check readBuffer for every occurence of o.id, overwrite if found
// whether found or not, we'll append o to the readbuffer
for (i = 0; i < this.readBuffer.length - 1; i++) {
r = this.readBuffer[i + 1]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
this.readBuffer[i] = o
} else {
this.readBuffer[i] = r
}
}
this.readBuffer[this.readBuffer.length - 1] = o
}
* delete (id) {
var i, r
for (i = 0; i < this.readBuffer.length; i++) {
r = this.readBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
this.readBuffer[i] = {
id: [null, null]
}
}
}
yield * this.flush()
yield * super.delete(id)
}
* findWithLowerBound (id) {
var o = yield * this.find(id, true)
if (o != null) {
return o
} else {
yield * this.flush()
return yield * super.findWithLowerBound.apply(this, arguments)
}
}
* findWithUpperBound (id) {
var o = yield * this.find(id, true)
if (o != null) {
return o
} else {
yield * this.flush()
return yield * super.findWithUpperBound.apply(this, arguments)
}
}
* findNext () {
yield * this.flush()
return yield * super.findNext.apply(this, arguments)
}
* findPrev () {
yield * this.flush()
return yield * super.findPrev.apply(this, arguments)
}
* iterate () {
yield * this.flush()
yield * super.iterate.apply(this, arguments)
}
* flush () {
for (var i = 0; i < this.writeBuffer.length; i++) {
var write = this.writeBuffer[i]
if (write.id[0] !== null) {
yield * super.put(write)
this.writeBuffer[i] = {
id: [null, null]
}
}
}
}
}
return SmallLookupBuffer
}
Y.utils.createSmallLookupBuffer = createSmallLookupBuffer
// Generates a unique id, for use as a user id.
// Thx to @jed for this script https://gist.github.com/jed/982883
function generateGuid(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,generateGuid)} // eslint-disable-line
Y.utils.generateGuid = generateGuid
}

61
src/index.js Normal file
View File

@@ -0,0 +1,61 @@
export {
Doc,
Transaction,
YArray as Array,
YMap as Map,
YText as Text,
YXmlText as XmlText,
YXmlHook as XmlHook,
YXmlElement as XmlElement,
YXmlFragment as XmlFragment,
YXmlEvent,
YMapEvent,
YArrayEvent,
YEvent,
Item,
AbstractStruct,
GC,
ContentBinary,
ContentDeleted,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentAny,
ContentString,
ContentType,
AbstractType,
RelativePosition,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
writeRelativePosition,
readRelativePosition,
ID,
createID,
compareIDs,
getState,
Snapshot,
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
snapshot,
emptySnapshot,
findRootTypeKey,
typeListToArraySnapshot,
typeMapGetSnapshot,
iterateDeletedStructs,
applyUpdate,
encodeStateAsUpdate,
encodeStateVector,
UndoManager,
decodeSnapshot,
encodeSnapshot,
isDeleted,
isParentOf,
equalSnapshots,
PermanentUserData, // @TODO experimental
tryGc,
transact
} from './internals.js'

36
src/internals.js Normal file
View File

@@ -0,0 +1,36 @@
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/YEvent.js'
export * from './types/AbstractType.js'
export * from './types/YArray.js'
export * from './types/YMap.js'
export * from './types/YText.js'
export * from './types/YXmlFragment.js'
export * from './types/YXmlElement.js'
export * from './types/YXmlEvent.js'
export * from './types/YXmlHook.js'
export * from './types/YXmlText.js'
export * from './structs/AbstractStruct.js'
export * from './structs/GC.js'
export * from './structs/ContentBinary.js'
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'

View File

@@ -0,0 +1,93 @@
import {
StructStore, ID, Transaction // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
import * as error from 'lib0/error.js'
/**
* @private
*/
export class AbstractStruct {
/**
* @param {ID} id
* @param {number} length
*/
constructor (id, length) {
/**
* The uniqe identifier of this struct.
* @type {ID}
* @readonly
*/
this.id = id
this.length = length
this.deleted = false
}
/**
* Merge this struct with the item to the right.
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
* Also this method does *not* remove right from StructStore!
* @param {AbstractStruct} right
* @return {boolean} wether this merged with right
*/
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) {
throw error.methodUnimplemented()
}
}

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

90
src/structs/GC.js Normal file
View File

@@ -0,0 +1,90 @@
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
/**
* @private
*/
export class GC extends AbstractStruct {
/**
* @param {ID} id
* @param {number} length
*/
constructor (id, length) {
super(id, length)
this.deleted = true
}
delete () {}
/**
* @param {GC} right
* @return {boolean}
*/
mergeWith (right) {
this.length += right.length
return true
}
/**
* @param {Transaction} transaction
*/
integrate (transaction) {
addStruct(transaction.doc.store, this)
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
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}
*/
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
)
}
}

785
src/structs/Item.js Normal file
View File

@@ -0,0 +1,785 @@
import {
readID,
createID,
writeID,
GC,
nextID,
AbstractStructRef,
AbstractStruct,
replaceStruct,
addStruct,
addToDeleteSet,
findRootTypeKey,
compareIDs,
getItem,
getItemCleanEnd,
getItemCleanStart,
readContentDeleted,
readContentBinary,
readContentJSON,
readContentAny,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
addChangedTypeToTransaction,
ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
* @todo This should return several items
*
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
*/
export const followRedone = (store, id) => {
/**
* @type {ID|null}
*/
let nextID = id
let diff = 0
let item
do {
if (diff > 0) {
nextID = createID(nextID.client, nextID.clock + diff)
}
item = getItem(store, nextID)
diff = nextID.clock - item.id.clock
nextID = item.redone
} while (nextID !== null && item instanceof Item)
return {
item, diff
}
}
/**
* Make sure that neither item nor any of its parents is ever deleted.
*
* This property does not persist when storing it into a database or when
* sending it to other peers
*
* @param {Item|null} item
* @param {boolean} keep
*/
export const keepItem = (item, keep) => {
while (item !== null && item.keep !== keep) {
item.keep = keep
item = item.parent._item
}
}
/**
* Split leftItem into two items
* @param {Transaction} transaction
* @param {Item} leftItem
* @param {number} diff
* @return {Item}
*
* @function
* @private
*/
export const splitItem = (transaction, leftItem, diff) => {
const id = leftItem.id
// create rightItem
const rightItem = new Item(
createID(id.client, id.clock + diff),
leftItem,
createID(id.client, id.clock + diff - 1),
leftItem.right,
leftItem.rightOrigin,
leftItem.parent,
leftItem.parentSub,
leftItem.content.splice(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
if (rightItem.right !== null) {
rightItem.right.left = rightItem
}
// right is more specific.
transaction._mergeStructs.add(rightItem.id)
// update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) {
rightItem.parent._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
return rightItem
}
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems) => {
if (item.redone !== null) {
return getItemCleanStart(transaction, item.redone)
}
let parentItem = item.parent._item
/**
* @type {Item|null}
*/
let left
/**
* @type {Item|null}
*/
let right
if (item.parentSub === null) {
// Is an array item. Insert at the old position
left = item.left
right = item
} else {
// Is a map item. Insert as current value
left = item
while (left.right !== null) {
left = left.right
if (left.id.client !== transaction.doc.clientID) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
}
if (left.right !== null) {
left = /** @type {Item} */ (item.parent._map.get(item.parentSub))
}
right = null
}
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) {
return null
}
}
if (parentItem !== null && parentItem.redone !== null) {
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
// find next cloned_redo items
while (left !== null) {
/**
* @type {Item|null}
*/
let leftTrace = left
// trace redone until parent matches
while (leftTrace !== null && leftTrace.parent._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && leftTrace.parent._item === parentItem) {
left = leftTrace
break
}
left = left.left
}
while (right !== null) {
/**
* @type {Item|null}
*/
let rightTrace = right
// trace redone until parent matches
while (rightTrace !== null && rightTrace.parent._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && rightTrace.parent._item === parentItem) {
right = rightTrace
break
}
right = right.right
}
}
const redoneItem = new Item(
nextID(transaction),
left, left === null ? null : left.lastId,
right, right === null ? null : right.id,
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
item.parentSub,
item.content.copy()
)
item.redone = redoneItem.id
keepItem(redoneItem, true)
redoneItem.integrate(transaction)
return redoneItem
}
/**
* Abstract class that represents any content.
*/
export class Item extends AbstractStruct {
/**
* @param {ID} id
* @param {Item | null} left
* @param {ID | null} origin
* @param {Item | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>} parent
* @param {string | null} parentSub
* @param {AbstractContent} content
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
super(id, content.getLength())
/**
* The item that was originally to the left of this item.
* @type {ID | null}
* @readonly
*/
this.origin = origin
/**
* The item that is currently to the left of this item.
* @type {Item | null}
*/
this.left = left
/**
* The item that is currently to the right of this item.
* @type {Item | null}
*/
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
*/
this.parent = parent
/**
* 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}
* @readonly
*/
this.parentSub = parentSub
/**
* Whether this item was deleted or not.
* @type {Boolean}
*/
this.deleted = false
/**
* If this type's effect is reundone this type refers to the type that undid
* this operation.
* @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
}
/**
* @param {Transaction} transaction
* @private
*/
integrate (transaction) {
const store = transaction.doc.store
const id = this.id
const parent = this.parent
const parentSub = this.parentSub
const length = this.length
/**
* @type {Item|null}
*/
let o
// set o to the first conflicting item
if (this.left !== null) {
o = this.left.right
} else if (parentSub !== null) {
o = parent._map.get(parentSub) || null
while (o !== null && o.left !== null) {
o = o.left
}
} else {
o = parent._start
}
// 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()
}
} else {
break
}
o = o.right
}
// reconnect left/right + update parent map/start if necessary
if (this.left !== null) {
const right = this.left.right
this.right = right
this.left.right = this
} else {
let r
if (parentSub !== null) {
r = parent._map.get(parentSub) || null
while (r !== null && r.left !== null) {
r = r.left
}
} else {
r = parent._start
parent._start = this
}
this.right = r
}
if (this.right !== null) {
this.right.left = this
} else if (parentSub !== null) {
// set as current parent value if right === null and this is parentSub
parent._map.set(parentSub, this)
if (this.left !== null) {
// this is the current attribute value of parent. delete right
this.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)
// 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)
}
}
/**
* Returns the next non-deleted item
* @private
*/
get next () {
let n = this.right
while (n !== null && n.deleted) {
n = n.right
}
return n
}
/**
* Returns the previous non-deleted item
* @private
*/
get prev () {
let n = this.left
while (n !== null && n.deleted) {
n = n.left
}
return n
}
/**
* Computes the last content address of this Item.
*/
get lastId () {
return createID(this.id.client, this.id.clock + this.length - 1)
}
/**
* Try to merge two items
*
* @param {Item} right
* @return {boolean}
*/
mergeWith (right) {
if (
compareIDs(right.origin, this.lastId) &&
this.right === right &&
compareIDs(this.rightOrigin, right.rightOrigin) &&
this.id.client === right.id.client &&
this.id.clock + this.length === right.id.clock &&
this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content)
) {
if (right.keep) {
this.keep = true
}
this.right = right.right
if (this.right !== null) {
this.right.left = this
}
this.length += right.length
return true
}
return false
}
/**
* Mark this Item as deleted.
*
* @param {Transaction} transaction
*/
delete (transaction) {
if (!this.deleted) {
const parent = this.parent
// adjust the length of parent
if (this.countable && this.parentSub === null) {
parent._length -= this.length
}
this.deleted = true
addToDeleteSet(transaction.deleteSet, this.id, this.length)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
this.content.delete(transaction)
}
}
/**
* @param {StructStore} store
* @param {boolean} parentGCd
*
* @private
*/
gc (store, parentGCd) {
if (!this.deleted) {
throw error.unexpectedCase()
}
this.content.gc(store)
if (parentGCd) {
replaceStruct(store, this, new GC(this.id, this.length))
} else {
this.content = new ContentDeleted(this.length)
}
}
/**
* 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.
*
* @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
const rightOrigin = this.rightOrigin
const parentSub = this.parentSub
const info = (this.content.getRef() & binary.BITS5) |
(origin === null ? 0 : binary.BIT8) | // origin is defined
(rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined
(parentSub === null ? 0 : binary.BIT6) // parentSub is non-null
encoding.writeUint8(encoder, info)
if (origin !== null) {
writeID(encoder, origin)
}
if (rightOrigin !== null) {
writeID(encoder, rightOrigin)
}
if (origin === null && rightOrigin === null) {
const parent = this.parent
if (parent._item === null) {
// parent type on y._map
// find the correct key
const ykey = findRootTypeKey(parent)
encoding.writeVarUint(encoder, 1) // write parentYKey
encoding.writeVarString(encoder, ykey)
} else {
encoding.writeVarUint(encoder, 0) // write parent id
writeID(encoder, parent._item.id)
}
if (parentSub !== null) {
encoding.writeVarString(encoder, parentSub)
}
}
this.content.write(encoder, offset)
}
}
/**
* @param {decoding.Decoder} decoder
* @param {number} info
*/
const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
/**
* A lookup map for reading Item content.
*
* @type {Array<function(decoding.Decoder):AbstractContent>}
*/
export const contentRefs = [
() => { throw error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted,
readContentJSON,
readContentBinary,
readContentString,
readContentEmbed,
readContentFormat,
readContentType,
readContentAny
]
/**
* Do not implement this class!
*/
export class AbstractContent {
/**
* @return {number}
*/
getLength () {
throw error.methodUnimplemented()
}
/**
* @return {Array<any>}
*/
getContent () {
throw error.methodUnimplemented()
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
*
* * Whether this Item should be addressable via `yarray.get(i)`
* * Whether this Item should be counted when computing yarray.length
*
* @return {boolean}
*/
isCountable () {
throw error.methodUnimplemented()
}
/**
* @return {AbstractContent}
*/
copy () {
throw error.methodUnimplemented()
}
/**
* @param {number} offset
* @return {AbstractContent}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {AbstractContent} right
* @return {boolean}
*/
mergeWith (right) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
throw error.methodUnimplemented()
}
/**
* @param {StructStore} store
*/
gc (store) {
throw error.methodUnimplemented()
}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
throw error.methodUnimplemented()
}
/**
* @return {number}
*/
getRef () {
throw error.methodUnimplemented()
}
}
/**
* @private
*/
export class ItemRef extends AbstractStructRef {
/**
* @param {decoding.Decoder} decoder
* @param {ID} id
* @param {number} info
*/
constructor (decoder, id, info) {
super(id)
/**
* The item that was originally to the left of this item.
* @type {ID | null}
*/
this.left = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
/**
* The item that was originally to the right of this item.
* @type {ID | null}
*/
this.right = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
/**
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
* and we read the next string as parentYKey.
* It indicates how we store/retrieve parent from `y.share`
* @type {string|null}
*/
this.parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
/**
* The parent type.
* @type {ID | null}
*/
this.parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : null
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
*/
this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
const missing = this._missing
if (this.left !== null) {
missing.push(this.left)
}
if (this.right !== null) {
missing.push(this.right)
}
if (this.parent !== null) {
missing.push(this.parent)
}
/**
* @type {AbstractContent}
*/
this.content = readItemContent(decoder, info)
this.length = this.content.getLength()
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {number} offset
* @return {Item|GC}
*/
toStruct (transaction, store, offset) {
if (offset > 0) {
/**
* @type {ID}
*/
const id = this.id
this.id = createID(id.client, id.clock + offset)
this.left = createID(this.id.client, this.id.clock - 1)
this.content = this.content.splice(offset)
this.length -= offset
}
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
const right = this.right === null ? null : getItemCleanStart(transaction, this.right)
let parent = null
let parentSub = this.parentSub
if (this.parent !== null) {
const parentItem = getItem(store, this.parent)
// Edge case: toStruct is called with an offset > 0. In this case left is defined.
// Depending in which order structs arrive, left may be GC'd and the parent not
// deleted. This is why we check if left is GC'd. Strictly we don't have
// to check if right is GC'd, but we will in case we run into future issues
if (!parentItem.deleted && (left === null || left.constructor !== GC) && (right === null || right.constructor !== GC)) {
parent = /** @type {ContentType} */ (parentItem.content).type
}
} else if (this.parentYKey !== null) {
parent = transaction.doc.get(this.parentYKey)
} else if (left !== null) {
if (left.constructor !== GC) {
parent = left.parent
parentSub = left.parentSub
}
} else if (right !== null) {
if (right.constructor !== GC) {
parent = right.parent
parentSub = right.parentSub
}
} else {
throw error.unexpectedCase()
}
return parent === null
? new GC(this.id, this.length)
: new Item(
this.id,
left,
this.left,
right,
this.right,
parent,
parentSub,
this.content
)
}
}

601
src/types/AbstractType.js Normal file
View File

@@ -0,0 +1,601 @@
import {
removeEventHandlerListener,
callEventHandlerListeners,
addEventHandlerListener,
createEventHandler,
nextID,
isVisible,
ContentType,
ContentAny,
ContentBinary,
createID,
getItemCleanStart,
Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
import * as iterator from 'lib0/iterator.js'
import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
/**
* 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
* @param {Transaction} transaction
* @param {EventType} event
*/
export const callTypeObservers = (type, transaction, event) => {
const changedType = type
const changedParentTypes = transaction.changedParentTypes
while (true) {
// @ts-ignore
map.setIfUndefined(changedParentTypes, type, () => []).push(event)
if (type._item === null) {
break
}
type = type._item.parent
}
callEventHandlerListeners(changedType._eH, event, transaction)
}
/**
* @template EventType
* Abstract Yjs Type class
*/
export class AbstractType {
constructor () {
/**
* @type {Item|null}
*/
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
this._length = 0
/**
* Event handlers
* @type {EventHandler<EventType,Transaction>}
*/
this._eH = createEventHandler()
/**
* Deep event handlers
* @type {EventHandler<Array<YEvent>,Transaction>}
*/
this._dEH = createEventHandler()
}
/**
* 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|null} item
* @private
*/
_integrate (y, item) {
this.doc = y
this._item = item
}
/**
* @return {AbstractType<EventType>}
* @private
*/
_copy () {
throw error.methodUnimplemented()
}
/**
* @param {encoding.Encoder} encoder
* @private
*/
_write (encoder) { }
/**
* The first non-deleted item
*/
get _first () {
let n = this._start
while (n !== null && n.deleted) {
n = n.right
}
return n
}
/**
* Creates YEvent and calls all type observers.
* Must be implemented by each type.
*
* @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 */ }
/**
* Observe all events that are created on this type.
*
* @param {function(EventType, Transaction):void} f Observer function
*/
observe (f) {
addEventHandlerListener(this._eH, f)
}
/**
* Observe all events that are created by this type and its children.
*
* @param {function(Array<YEvent>,Transaction):void} f Observer function
*/
observeDeep (f) {
addEventHandlerListener(this._dEH, f)
}
/**
* Unregister an observer function.
*
* @param {function(EventType,Transaction):void} f Observer function
*/
unobserve (f) {
removeEventHandlerListener(this._eH, f)
}
/**
* Unregister an observer function.
*
* @param {function(Array<YEvent>,Transaction):void} f Observer function
*/
unobserveDeep (f) {
removeEventHandlerListener(this._dEH, f)
}
/**
* @abstract
* @return {any}
*/
toJSON () {}
}
/**
* @param {AbstractType<any>} type
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArray = type => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* @param {AbstractType<any>} type
* @param {Snapshot} snapshot
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* Executes a provided function on once on overy element of this YArray.
*
* @param {AbstractType<any>} type
* @param {function(any,number,any):void} f A function to execute on every element of this YArray.
*
* @private
* @function
*/
export const typeListForEach = (type, f) => {
let index = 0
let n = type._start
while (n !== null) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @template C,R
* @param {AbstractType<any>} type
* @param {function(C,number,AbstractType<any>):R} f
* @return {Array<R>}
*
* @private
* @function
*/
export const typeListMap = (type, f) => {
/**
* @type {Array<any>}
*/
const result = []
typeListForEach(type, (c, i) => {
result.push(f(c, i, type))
})
return result
}
/**
* @param {AbstractType<any>} type
* @return {IterableIterator<any>}
*
* @private
* @function
*/
export const typeListCreateIterator = type => {
let n = type._start
/**
* @type {Array<any>|null}
*/
let currentContent = null
let currentContentIndex = 0
return {
[Symbol.iterator] () {
return this
},
next: () => {
// find some content
if (currentContent === null) {
while (n !== null && n.deleted) {
n = n.right
}
// check if we reached the end, no need to check currentContent, because it does not exist
if (n === null) {
return {
done: true,
value: undefined
}
}
// we found n, so we can set currentContent
currentContent = n.content.getContent()
currentContentIndex = 0
n = n.right // we used the content of n, now iterate to next
}
const value = currentContent[currentContentIndex++]
// check if we need to empty currentContent
if (currentContent.length <= currentContentIndex) {
currentContent = null
}
return {
done: false,
value
}
}
}
}
/**
* Executes a provided function on once on overy element of this YArray.
* Operates on a snapshotted state of the document.
*
* @param {AbstractType<any>} type
* @param {function(any,number,AbstractType<any>):void} f A function to execute on every element of this YArray.
* @param {Snapshot} snapshot
*
* @private
* @function
*/
export const typeListForEachSnapshot = (type, f, snapshot) => {
let index = 0
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
* @return {any}
*
* @private
* @function
*/
export const typeListGet = (type, index) => {
for (let n = type._start; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
return n.content.getContent()[index]
}
index -= n.length
}
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item?} referenceItem
* @param {Array<Object<string,any>|Array<any>|boolean|number|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
let left = referenceItem
const right = referenceItem === null ? parent._start : referenceItem.right
/**
* @type {Array<Object|Array<any>|number>}
*/
let jsonContent = []
const packJsonContent = () => {
if (jsonContent.length > 0) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction)
jsonContent = []
}
}
content.forEach(c => {
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
break
default:
packJsonContent()
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)
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)
} else {
throw new Error('Unexpected content type in insert operation')
}
}
}
})
packJsonContent()
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {Array<Object<string,any>|Array<any>|number|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index === 0) {
return typeListInsertGenericsAfter(transaction, parent, null, content)
}
let n = parent._start
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index <= n.length) {
if (index < n.length) {
// insert in-between
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
break
}
index -= n.length
}
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {number} length
*
* @private
* @function
*/
export const typeListDelete = (transaction, parent, index, length) => {
if (length === 0) { return }
let n = parent._start
// compute the first item to be deleted
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
index -= n.length
}
}
// delete all items until done
while (length > 0 && n !== null) {
if (!n.deleted) {
if (length < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
}
n.delete(transaction)
length -= n.length
}
n = n.right
}
if (length > 0) {
throw error.create('array length exceeded')
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
*
* @private
* @function
*/
export const typeMapDelete = (transaction, parent, key) => {
const c = parent._map.get(key)
if (c !== undefined) {
c.delete(transaction)
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Object|number|Array<any>|string|Uint8Array|AbstractType<any>} value
*
* @private
* @function
*/
export const typeMapSet = (transaction, parent, key, value) => {
const left = parent._map.get(key) || null
let content
if (value == null) {
content = new ContentAny([value])
} else {
switch (value.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
content = new ContentAny([value])
break
case Uint8Array:
content = new ContentBinary(/** @type {Uint8Array} */ (value))
break
default:
if (value instanceof AbstractType) {
content = new ContentType(value)
} else {
throw new Error('Unexpected content type')
}
}
}
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction)
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
*/
export const typeMapGet = (parent, key) => {
const val = parent._map.get(key)
return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined
}
/**
* @param {AbstractType<any>} parent
* @return {Object<string,Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
*
* @private
* @function
*/
export const typeMapGetAll = (parent) => {
/**
* @type {Object<string,any>}
*/
const res = {}
for (const [key, value] of parent._map) {
if (!value.deleted) {
res[key] = value.content.getContent()[value.length - 1]
}
}
return res
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {boolean}
*
* @private
* @function
*/
export const typeMapHas = (parent, key) => {
const val = parent._map.get(key)
return val !== undefined && !val.deleted
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Snapshot} snapshot
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
*/
export const typeMapGetSnapshot = (parent, key, snapshot) => {
let v = parent._map.get(key) || null
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
}
/**
* @param {Map<string,Item>} map
* @return {IterableIterator<Array<any>>}
*
* @private
* @function
*/
export const createMapIterator = map => iterator.iteratorFilter(map.entries(), /** @param {any} entry */ entry => !entry[1].deleted)

216
src/types/YArray.js Normal file
View File

@@ -0,0 +1,216 @@
/**
* @module YArray
*/
import {
YEvent,
AbstractType,
typeListGet,
typeListToArray,
typeListForEach,
typeListCreateIterator,
typeListInsertGenerics,
typeListDelete,
typeListMap,
YArrayRefID,
callTypeObservers,
transact,
Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js'
/**
* Event that describes the changes on a YArray
* @template T
*/
export class YArrayEvent extends YEvent {
/**
* @param {YArray<T>} yarray The changed type
* @param {Transaction} transaction The transaction object
*/
constructor (yarray, transaction) {
super(yarray, transaction)
this._transaction = transaction
}
}
/**
* A shared Array implementation.
* @template T
* @extends AbstractType<YArrayEvent<T>>
* @implements {IterableIterator<T>}
*/
export class YArray extends AbstractType {
constructor () {
super()
/**
* @type {Array<any>?}
* @private
*/
this._prelimContent = []
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this._prelimContent = null
}
_copy () {
return new 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))
}
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(1, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
/**
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*/
push (content) {
this.insert(this.length, content)
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
}
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {T}
*/
get (index) {
return typeListGet(this, index)
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array<T>}
*/
toArray () {
return typeListToArray(this)
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Array<any>}
*/
toJSON () {
return this.map(c => c instanceof AbstractType ? c.toJSON() : c)
}
/**
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @template T,M
* @param {function(T,number,YArray<T>):M} f Function that produces an element of the new Array
* @return {Array<M>} A new array with each element being the result of the
* callback function
*/
map (f) {
return typeListMap(this, /** @type {any} */ (f))
}
/**
* Executes a provided function on once on overy element of this YArray.
*
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
typeListForEach(this, f)
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return typeListCreateIterator(this)
}
/**
* @param {encoding.Encoder} encoder
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YArrayRefID)
}
}
/**
* @param {decoding.Decoder} decoder
*
* @private
* @function
*/
export const readYArray = decoder => new YArray()

232
src/types/YMap.js Normal file
View File

@@ -0,0 +1,232 @@
/**
* @module YMap
*/
import {
YEvent,
AbstractType,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapHas,
createMapIterator,
YMapRefID,
callTypeObservers,
transact,
Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as iterator from 'lib0/iterator.js'
/**
* @template T
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
/**
* @param {YMap<T>} ymap The YArray that changed.
* @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed.
*/
constructor (ymap, transaction, subs) {
super(ymap, transaction)
this.keysChanged = subs
}
}
/**
* @template T number|string|Object|Array|Uint8Array
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<T>>
* @implements {IterableIterator}
*/
export class YMap extends AbstractType {
constructor () {
super()
/**
* @type {Map<string,any>?}
* @private
*/
this._prelimContent = new Map()
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
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))
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object<string,T>}
*/
toJSON () {
/**
* @type {Object<string,T>}
*/
const 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
}
}
return map
}
/**
* Returns the keys for each element in the YMap Type.
*
* @return {IterableIterator<string>}
*/
keys () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0])
}
/**
* Returns the keys for each element in the YMap Type.
*
* @return {IterableIterator<string>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
}
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<any>}
*/
entries () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => [v[0], v[1].content.getContent()[v[1].length - 1]])
}
/**
* Executes a provided function on once on overy key-value pair.
*
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
/**
* @type {Object<string,T>}
*/
const map = {}
for (const [key, item] of this._map) {
if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this)
}
}
return map
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return this.entries()
}
/**
* Remove a specified element from this YMap.
*
* @param {string} key The key of the element to remove.
*/
delete (key) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapDelete(transaction, this, key)
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).delete(key)
}
}
/**
* Adds or updates an element with a specified key and value.
*
* @param {string} key The key of the element to add to this YMap
* @param {T} value The value of the element to add
*/
set (key, value) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, key, value)
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
}
return value
}
/**
* Returns a specified element from this YMap.
*
* @param {string} key
* @return {T|undefined}
*/
get (key) {
return /** @type {any} */ (typeMapGet(this, key))
}
/**
* Returns a boolean indicating whether the specified key exists or not.
*
* @param {string} key The key to test.
* @return {boolean}
*/
has (key) {
return typeMapHas(this, key)
}
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YMapRefID)
}
}
/**
* @param {decoding.Decoder} decoder
*
* @private
* @function
*/
export const readYMap = decoder => new YMap()

944
src/types/YText.js Normal file
View File

@@ -0,0 +1,944 @@
/**
* @module YText
*/
import {
YEvent,
AbstractType,
nextID,
createID,
getItemCleanStart,
isVisible,
YTextRefID,
callTypeObservers,
transact,
ContentEmbed,
ContentFormat,
ContentString,
splitSnapshotAffectedStructs,
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'
/**
* @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 {
/**
* @param {Item|null} left
* @param {Item|null} right
*/
constructor (left, right) {
this.left = left
this.right = right
}
}
export class ItemTextListPosition extends ItemListPosition {
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
*/
constructor (left, right, currentAttributes) {
super(left, right)
this.currentAttributes = currentAttributes
}
}
export class ItemInsertionResult extends ItemListPosition {
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} negatedAttributes
*/
constructor (left, right, negatedAttributes) {
super(left, right)
this.negatedAttributes = negatedAttributes
}
}
/**
* @param {Transaction} transaction
* @param {Map<string,any>} currentAttributes
* @param {Item|null} left
* @param {Item|null} right
* @param {number} count
* @return {ItemTextListPosition}
*
* @private
* @function
*/
const findNextPosition = (transaction, currentAttributes, left, right, count) => {
while (right !== null && count > 0) {
switch (right.content.constructor) {
case ContentEmbed:
case ContentString:
if (!right.deleted) {
if (count < right.length) {
// split right
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + count))
}
count -= right.length
}
break
case ContentFormat:
if (!right.deleted) {
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
}
break
}
left = right
right = right.right
}
return new ItemTextListPosition(left, right, currentAttributes)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @return {ItemTextListPosition}
*
* @private
* @function
*/
const findPosition = (transaction, parent, index) => {
const currentAttributes = new Map()
const right = parent._start
return findNextPosition(transaction, currentAttributes, null, right, index)
}
/**
* Negate applied formats
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} negatedAttributes
* @return {ItemListPosition}
*
* @private
* @function
*/
const insertNegatedAttributes = (transaction, parent, left, right, negatedAttributes) => {
// check if we really need to remove attributes
while (
right !== null && (
right.deleted === true || (
right.content.constructor === ContentFormat &&
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
)
)
) {
if (!right.deleted) {
negatedAttributes.delete(/** @type {ContentFormat} */ (right.content).key)
}
left = right
right = right.right
}
for (const [key, val] of negatedAttributes) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
left.integrate(transaction)
}
return { left, right }
}
/**
* @param {Map<string,any>} currentAttributes
* @param {ContentFormat} format
*
* @private
* @function
*/
const updateCurrentAttributes = (currentAttributes, format) => {
const { key, value } = format
if (value === null) {
currentAttributes.delete(key)
} else {
currentAttributes.set(key, value)
}
}
/**
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
* @return {ItemListPosition}
*
* @private
* @function
*/
const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => {
// go right while attributes[right.key] === right.value (or right is deleted)
while (true) {
if (right === null) {
break
} else if (right.deleted) {
// continue
} 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 {
break
}
left = right
right = right.right
}
return new ItemListPosition(left, right)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
* @param {Object<string,any>} attributes
* @return {ItemInsertionResult}
*
* @private
* @function
**/
const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => {
const negatedAttributes = new Map()
// insert format-start items
for (const key in attributes) {
const val = attributes[key]
const currentVal = currentAttributes.get(key) || null
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)
}
}
return new ItemInsertionResult(left, right, negatedAttributes)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
* @param {string|object} text
* @param {Object<string,any>} attributes
* @return {ItemListPosition}
*
* @private
* @function
**/
const insertText = (transaction, parent, left, right, 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
// 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)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item|null} left
* @param {Item|null} right
* @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
// 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) {
switch (right.content.constructor) {
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
negatedAttributes.set(key, value)
}
right.delete(transaction)
}
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
break
}
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
break
}
}
left = right
right = right.right
}
// Quill just assumes that the editor starts with a newline and that it always
// ends with a newline. We only insert that newline when a new newline is
// inserted - i.e when length is bigger than type.length
if (length > 0) {
let newlines = ''
for (; length > 0; length--) {
newlines += '\n'
}
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentString(newlines))
left.integrate(transaction)
}
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
}
/**
* @param {Transaction} transaction
* @param {Item|null} left
* @param {Item|null} right
* @param {Map<string,any>} currentAttributes
* @param {number} length
* @return {ItemListPosition}
*
* @private
* @function
*/
const deleteText = (transaction, left, right, currentAttributes, length) => {
while (length > 0 && right !== null) {
if (right.deleted === false) {
switch (right.content.constructor) {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
break
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
right.delete(transaction)
break
}
}
left = right
right = right.right
}
return { left, right }
}
/**
* The Quill Delta format represents changes on a text document with
* formatting information. For mor information visit {@link https://quilljs.com/docs/delta/|Quill Delta}
*
* @example
* {
* ops: [
* { insert: 'Gandalf', attributes: { bold: true } },
* { insert: ' the ' },
* { insert: 'Grey', attributes: { color: '#cccccc' } }
* ]
* }
*
*/
/**
* Attributes that can be assigned to a selection of text.
*
* @example
* {
* bold: true,
* font-size: '40px'
* }
*
* @typedef {Object} TextAttributes
*/
/**
* @typedef {Object} DeltaItem
* @property {number|undefined} DeltaItem.delete
* @property {number|undefined} DeltaItem.retain
* @property {string|undefined} DeltaItem.string
* @property {Object<string,any>} DeltaItem.attributes
*/
/**
* Event that describes the changes on a YText type.
*/
export class YTextEvent extends YEvent {
/**
* @param {YText} ytext
* @param {Transaction} transaction
*/
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.
*
* @type {Array<DeltaItem>}
*
* @public
*/
get delta () {
if (this._delta === null) {
const y = /** @type {Doc} */ (this.target.doc)
this._delta = []
transact(y, transaction => {
const delta = /** @type {Array<DeltaItem>} */ (this._delta)
const currentAttributes = new Map() // saves all current attributes for insert
const oldAttributes = new Map()
let item = this.target._start
/**
* @type {string?}
*/
let action = null
/**
* @type {Object<string,any>}
*/
const attributes = {} // counts added or removed new attributes for retain
/**
* @type {string|object}
*/
let insert = ''
let retain = 0
let deleteLen = 0
const addOp = () => {
if (action !== null) {
/**
* @type {any}
*/
let op
switch (action) {
case 'delete':
op = { delete: deleteLen }
deleteLen = 0
break
case 'insert':
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
for (const [key, value] of currentAttributes) {
if (value !== null) {
op.attributes[key] = value
}
}
}
insert = ''
break
case 'retain':
op = { retain }
if (Object.keys(attributes).length > 0) {
op.attributes = {}
for (const key in attributes) {
op.attributes[key] = attributes[key]
}
}
retain = 0
break
}
delta.push(op)
action = null
}
}
while (item !== null) {
switch (item.content.constructor) {
case ContentEmbed:
if (this.adds(item)) {
if (!this.deletes(item)) {
addOp()
action = 'insert'
insert = /** @type {ContentEmbed} */ (item.content).embed
addOp()
}
} else if (this.deletes(item)) {
if (action !== 'delete') {
addOp()
action = 'delete'
}
deleteLen += 1
} else if (!item.deleted) {
if (action !== 'retain') {
addOp()
action = 'retain'
}
retain += 1
}
break
case ContentString:
if (this.adds(item)) {
if (!this.deletes(item)) {
if (action !== 'insert') {
addOp()
action = 'insert'
}
insert += /** @type {ContentString} */ (item.content).str
}
} else if (this.deletes(item)) {
if (action !== 'delete') {
addOp()
action = 'delete'
}
deleteLen += item.length
} else if (!item.deleted) {
if (action !== 'retain') {
addOp()
action = 'retain'
}
retain += item.length
}
break
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (item.content)
if (this.adds(item)) {
if (!this.deletes(item)) {
const curVal = currentAttributes.get(key) || null
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
if (equalAttrs(value, (oldAttributes.get(key) || null))) {
delete attributes[key]
} else {
attributes[key] = value
}
} else {
item.delete(transaction)
}
}
} else if (this.deletes(item)) {
oldAttributes.set(key, value)
const curVal = currentAttributes.get(key) || null
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
attributes[key] = curVal
}
} else if (!item.deleted) {
oldAttributes.set(key, value)
const attr = attributes[key]
if (attr !== undefined) {
if (!equalAttrs(attr, value)) {
if (action === 'retain') {
addOp()
}
if (value === null) {
attributes[key] = value
} else {
delete attributes[key]
}
} else {
item.delete(transaction)
}
}
}
if (!item.deleted) {
if (action === 'insert') {
addOp()
}
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (item.content))
}
break
}
}
item = item.right
}
addOp()
while (delta.length > 0) {
const lastOp = delta[delta.length - 1]
if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
// retain delta's if they don't assign attributes
delta.pop()
} else {
break
}
}
})
}
return this._delta
}
}
/**
* Type that represents text with formatting information.
*
* This type replaces y-richtext as this implementation is able to handle
* block formats (format information on a paragraph), embeds (complex elements
* like pictures and videos), and text formats (**bold**, *italic*).
*
* @extends AbstractType<YTextEvent>
*/
export class YText extends AbstractType {
/**
* @param {String} [string] The initial value of the YText.
*/
constructor (string) {
super()
/**
* 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
}
/**
* @param {Doc} y
* @param {Item} item
*
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
try {
/** @type {Array<function>} */ (this._pending).forEach(f => f())
} catch (e) {
console.error(e)
}
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))
}
/**
* Returns the unformatted string representation of this YText type.
*
* @public
*/
toString () {
let str = ''
/**
* @type {Item|null}
*/
let n = this._start
while (n !== null) {
if (!n.deleted && n.countable && n.content.constructor === ContentString) {
str += /** @type {ContentString} */ (n.content).str
}
n = n.right
}
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.
*
* @public
*/
applyDelta (delta) {
if (this.doc !== null) {
transact(this.doc, transaction => {
/**
* @type {ItemListPosition}
*/
let pos = 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) {
// Quill assumes that the content starts with an empty paragraph.
// Yjs/Y.Text assumes that it starts empty. We always hide that
// there is a newline at the end of the content.
// If we omit this step, clients will see a different number of
// paragraphs, but nothing bad will happen.
const ins = (typeof op.insert === 'string' && i === delta.length - 1 && pos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
if (typeof ins !== 'string' || ins.length > 0) {
pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, ins, op.attributes || {})
}
} else if (op.retain !== undefined) {
pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {})
} else if (op.delete !== undefined) {
pos = deleteText(transaction, pos.left, pos.right, currentAttributes, op.delete)
}
}
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.applyDelta(delta))
}
}
/**
* Returns the Delta representation of this YText type.
*
* @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, computeYChange) {
/**
* @type{Array<any>}
*/
const ops = []
const currentAttributes = new Map()
const doc = /** @type {Doc} */ (this.doc)
let str = ''
let n = this._start
function packStr () {
if (str.length > 0) {
// pack str with attributes to ops
/**
* @type {Object<string,any>}
*/
const attributes = {}
let addAttributes = false
for (const [key, value] of currentAttributes) {
addAttributes = true
attributes[key] = value
}
/**
* @type {Object<string,any>}
*/
const op = { insert: str }
if (addAttributes) {
op.attributes = attributes
}
ops.push(op)
str = ''
}
}
// snapshots are merged again after the transaction, so we need to keep the
// transalive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString: {
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
break
}
case ContentEmbed:
packStr()
ops.push({
insert: /** @type {ContentEmbed} */ (n.content).embed
})
break
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
}
n = n.right
}
packStr()
}, splitSnapshotAffectedStructs)
return ops
}
/**
* Insert text at a given index.
*
* @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
* information to apply on the inserted
* Text.
* @public
*/
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, this, index)
if (!attributes) {
attributes = {}
// @ts-ignore
currentAttributes.forEach((v, k) => { attributes[k] = v })
}
insertText(transaction, this, left, right, currentAttributes, text, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insert(index, text, attributes))
}
}
/**
* Inserts an embed at a index.
*
* @param {number} index The index to insert the embed at.
* @param {Object} embed The Object that represents the embed.
* @param {TextAttributes} attributes Attribute information to apply on the
* embed
*
* @public
*/
insertEmbed (index, embed, attributes = {}) {
if (embed.constructor !== Object) {
throw new Error('Embed must be an Object')
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, this, index)
insertText(transaction, this, left, right, currentAttributes, embed, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.insertEmbed(index, embed, attributes))
}
}
/**
* Deletes text starting from an index.
*
* @param {number} index Index at which to start deleting.
* @param {number} length The number of characters to remove. Defaults to 1.
*
* @public
*/
delete (index, length) {
if (length === 0) {
return
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, this, index)
deleteText(transaction, left, right, currentAttributes, length)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.delete(index, length))
}
}
/**
* Assigns properties to a range of text.
*
* @param {number} index The position where to start formatting.
* @param {number} length The amount of characters to assign properties to.
* @param {TextAttributes} attributes Attribute information to apply on the
* text.
*
* @public
*/
format (index, length, attributes) {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (right === null) {
return
}
formatText(transaction, this, left, right, currentAttributes, length, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))
}
}
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YTextRefID)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YText}
*
* @private
* @function
*/
export const readYText = decoder => new YText()

203
src/types/YXmlElement.js Normal file
View File

@@ -0,0 +1,203 @@
import {
YXmlFragment,
transact,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapGetAll,
typeListForEach,
YXmlElementRefID,
Snapshot, Doc, Item // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
*
* * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement
*/
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName
/**
* @type {Map<string, any>|null}
* @private
*/
this._prelimAttrs = new Map()
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
* @private
*/
_integrate (y, item) {
super._integrate(y, item)
;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
this.setAttribute(key, value)
})
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)
}
/**
* Returns the XML serialization of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements
*
* @return {string} The string representation of this type.
*
* @public
*/
toString () {
const attrs = this.getAttributes()
const stringBuilder = []
const keys = []
for (const key in attrs) {
keys.push(key)
}
keys.sort()
const keysLen = keys.length
for (let i = 0; i < keysLen; i++) {
const key = keys[i]
stringBuilder.push(key + '="' + attrs[key] + '"')
}
const nodeName = this.nodeName.toLocaleLowerCase()
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
}
/**
* Removes an attribute from this YXmlElement.
*
* @param {String} attributeName The attribute name that is to be removed.
*
* @public
*/
removeAttribute (attributeName) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapDelete(transaction, this, attributeName)
})
} else {
/** @type {Map<string,any>} */ (this._prelimAttrs).delete(attributeName)
}
}
/**
* Sets or updates an attribute.
*
* @param {String} attributeName The attribute name that is to be set.
* @param {String} attributeValue The attribute value that is to be set.
*
* @public
*/
setAttribute (attributeName, attributeValue) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, attributeName, attributeValue)
})
} else {
/** @type {Map<string, any>} */ (this._prelimAttrs).set(attributeName, attributeValue)
}
}
/**
* Returns an attribute value that belongs to the attribute name.
*
* @param {String} attributeName The attribute name that identifies the
* queried value.
* @return {String} The queried attribute value.
*
* @public
*/
getAttribute (attributeName) {
return /** @type {any} */ (typeMapGet(this, attributeName))
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @param {Snapshot} [snapshot]
* @return {Object<string, any>} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes (snapshot) {
return typeMapGetAll(this)
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
dom.setAttribute(key, attrs[key])
}
typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding))
})
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoding.writeVarUint(encoder, YXmlElementRefID)
encoding.writeVarString(encoder, this.nodeName)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YXmlElement}
*
* @private
* @function
*/
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))

39
src/types/YXmlEvent.js Normal file
View File

@@ -0,0 +1,39 @@
import {
YEvent,
YXmlElement, YXmlFragment, Transaction // eslint-disable-line
} from '../internals.js'
/**
* An Event that describes changes on a YXml Element or Yxml Fragment
*/
export class YXmlEvent extends YEvent {
/**
* @param {YXmlElement|YXmlFragment} target The target on which the event is created.
* @param {Set<string|null>} subs The set of changed attributes. `null` is included if the
* child list changed.
* @param {Transaction} transaction The transaction instance with wich the
* change was created.
*/
constructor (target, subs, transaction) {
super(target, transaction)
/**
* Whether the children changed.
* @type {Boolean}
* @private
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string|null>}
*/
this.attributesChanged = new Set()
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.attributesChanged.add(sub)
}
})
}
}

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

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

90
src/types/YXmlHook.js Normal file
View File

@@ -0,0 +1,90 @@
import {
YMap,
YXmlHookRefID
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* You can manage binding to a custom type with YXmlHook.
*
* @extends {YMap<any>}
*/
export class YXmlHook extends YMap {
/**
* @param {string} hookName nodeName of the Dom Node.
*/
constructor (hookName) {
super()
/**
* @type {string}
*/
this.hookName = hookName
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
return new YXmlHook(this.hookName)
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object.<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const hook = hooks[this.hookName]
let dom
if (hook !== undefined) {
dom = hook.createDom(this)
} else {
dom = document.createElement(this.hookName)
}
dom.setAttribute('data-yjs-hook', this.hookName)
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* 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.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
*
* @private
*/
_write (encoder) {
super._write(encoder)
encoding.writeVarUint(encoder, YXmlHookRefID)
encoding.writeVarString(encoder, this.hookName)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YXmlHook}
*
* @private
* @function
*/
export const readYXmlHook = decoder =>
new YXmlHook(decoding.readVarString(decoder))

97
src/types/YXmlText.js Normal file
View File

@@ -0,0 +1,97 @@
import { YText, YXmlTextRefID } from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
_copy () {
return new YXmlText()
}
/**
* Creates a Dom Element that mirrors this YXmlText.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Text} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks, binding) {
const dom = _document.createTextNode(this.toString())
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
toString () {
// @ts-ignore
return this.toDelta().map(delta => {
const nestedNodes = []
for (const nodeName in delta.attributes) {
const attrs = []
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
}).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/**
* @param {encoding.Encoder} encoder
*
* @private
*/
_write (encoder) {
encoding.writeVarUint(encoder, YXmlTextRefID)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YXmlText}
*
* @private
* @function
*/
export const readYXmlText = decoder => new YXmlText()

314
src/utils/DeleteSet.js Normal file
View File

@@ -0,0 +1,314 @@
import {
findIndexSS,
createID,
getState,
splitItem,
iterateStructs,
Item, 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'
import * as decoding from 'lib0/decoding.js'
export class DeleteItem {
/**
* @param {number} clock
* @param {number} len
*/
constructor (clock, len) {
/**
* @type {number}
*/
this.clock = clock
/**
* @type {number}
*/
this.len = len
}
}
/**
* We no longer maintain a DeleteStore. DeleteSet is a temporary object that is created when needed.
* - When created in a transaction, it must only be accessed after sorting, and merging
* - This DeleteSet is send to other clients
* - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore
* - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged.
*/
export class DeleteSet {
constructor () {
/**
* @type {Map<number,Array<DeleteItem>>}
* @private
*/
this.clients = new Map()
}
}
/**
* Iterate over all structs that the DeleteSet gc's.
*
* @param {Transaction} transaction
* @param {DeleteSet} ds
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateDeletedStructs = (transaction, ds, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) {
const del = deletes[i]
iterateStructs(transaction, structs, del.clock, del.len, f)
}
})
/**
* @param {Array<DeleteItem>} dis
* @param {number} clock
* @return {number|null}
*
* @private
* @function
*/
export const findIndexDS = (dis, clock) => {
let left = 0
let right = dis.length - 1
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = dis[midindex]
const midclock = mid.clock
if (midclock <= clock) {
if (clock < midclock + mid.len) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
}
return null
}
/**
* @param {DeleteSet} ds
* @param {ID} id
* @return {boolean}
*
* @private
* @function
*/
export const isDeleted = (ds, id) => {
const dis = ds.clients.get(id.client)
return dis !== undefined && findIndexDS(dis, id.clock) !== null
}
/**
* @param {DeleteSet} ds
*
* @private
* @function
*/
export const sortAndMergeDeleteSet = ds => {
ds.clients.forEach(dels => {
dels.sort((a, b) => a.clock - b.clock)
// merge items without filtering or splicing the array
// i is the current pointer
// j refers to the current insert position for the pointed item
// try to merge dels[i] into dels[j-1] or set dels[j]=dels[i]
let i, j
for (i = 1, j = 1; i < dels.length; i++) {
const left = dels[j - 1]
const right = dels[i]
if (left.clock + left.len === right.clock) {
left.len += right.len
} else {
if (j < i) {
dels[j] = right
}
j++
}
}
dels.length = j
})
}
/**
* @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
* @param {number} length
*
* @private
* @function
*/
export const addToDeleteSet = (ds, id, length) => {
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
}
export const createDeleteSet = () => new DeleteSet()
/**
* @param {StructStore} ss
* @return {DeleteSet} Merged and sorted DeleteSet
*
* @private
* @function
*/
export const createDeleteSetFromStructStore = ss => {
const ds = createDeleteSet()
ss.clients.forEach((structs, client) => {
/**
* @type {Array<DeleteItem>}
*/
const dsitems = []
for (let i = 0; i < structs.length; i++) {
const struct = structs[i]
if (struct.deleted) {
const clock = struct.id.clock
let len = struct.length
if (i + 1 < structs.length) {
for (let next = structs[i + 1]; i + 1 < structs.length && next.id.clock === clock + len && next.deleted; next = structs[++i + 1]) {
len += next.length
}
}
dsitems.push(new DeleteItem(clock, len))
}
}
if (dsitems.length > 0) {
ds.clients.set(client, dsitems)
}
})
return ds
}
/**
* @param {encoding.Encoder} encoder
* @param {DeleteSet} ds
*
* @private
* @function
*/
export const writeDeleteSet = (encoder, ds) => {
encoding.writeVarUint(encoder, ds.clients.size)
ds.clients.forEach((dsitems, client) => {
encoding.writeVarUint(encoder, client)
const len = dsitems.length
encoding.writeVarUint(encoder, len)
for (let i = 0; i < len; i++) {
const item = dsitems[i]
encoding.writeVarUint(encoder, item.clock)
encoding.writeVarUint(encoder, item.len)
}
})
}
/**
* @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
* @param {StructStore} store
*
* @private
* @function
*/
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
const client = decoding.readVarUint(decoder)
const numberOfDeletes = decoding.readVarUint(decoder)
const structs = store.clients.get(client) || []
const state = getState(store, client)
for (let i = 0; i < numberOfDeletes; i++) {
const clock = decoding.readVarUint(decoder)
const len = decoding.readVarUint(decoder)
if (clock < state) {
if (state < clock + len) {
addToDeleteSet(unappliedDS, createID(client, state), clock + len - state)
}
let index = findIndexSS(structs, clock)
/**
* We can ignore the case of GC and Delete structs, because we are going to skip them
* @type {Item}
*/
// @ts-ignore
let struct = structs[index]
// split the first item if necessary
if (!struct.deleted && struct.id.clock < clock) {
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
index++ // increase we now want to use the next struct
}
while (index < structs.length) {
// @ts-ignore
struct = structs[index++]
if (struct.id.clock < clock + len) {
if (!struct.deleted) {
if (clock + len < struct.id.clock + struct.length) {
structs.splice(index, 0, splitItem(transaction, struct, clock + len - struct.id.clock))
}
struct.delete(transaction)
}
} else {
break
}
}
} else {
addToDeleteSet(unappliedDS, createID(client, clock), len)
}
}
}
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)))
}
}

196
src/utils/Doc.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* @module Y
*/
import {
StructStore,
AbstractType,
YArray,
YText,
YMap,
YXmlFragment,
transact,
Item, Transaction, YEvent // eslint-disable-line
} from '../internals.js'
import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js'
/**
* A Yjs instance handles the state of shared data.
* @extends Observable<string>
*/
export class Doc extends Observable {
/**
* @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 ({ gc = true, gcFilter = () => true } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = random.uint32()
/**
* @type {Map<string, AbstractType<YEvent>>}
*/
this.share = new Map()
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
* that happened inside of the transaction are sent as one message to the
* other peers.
*
* @param {function(Transaction):void} f The function that should be executed as a transaction
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
*
* @public
*/
transact (f, origin = null) {
transact(this, f, origin)
}
/**
* Define a shared data type.
*
* Multiple calls of `y.get(name, TypeConstructor)` yield the same result
* and do not overwrite each other. I.e.
* `y.define(name, Y.Array) === y.define(name, Y.Array)`
*
* After this method is called, the type is also available on `y.share.get(name)`.
*
* *Best Practices:*
* Define all types right after the Yjs instance is created and store them in a separate object.
* Also use the typed methods `getText(name)`, `getArray(name)`, ..
*
* @example
* const y = new Y(..)
* const appState = {
* document: y.getText('document')
* comments: y.getArray('comments')
* }
*
* @param {string} name
* @param {Function} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ...
* @return {AbstractType<any>} The created type. Constructed with TypeConstructor
*
* @public
*/
get (name, TypeConstructor = AbstractType) {
const type = map.setIfUndefined(this.share, name, () => {
// @ts-ignore
const t = new TypeConstructor()
t._integrate(this, null)
return t
})
const Constr = type.constructor
if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) {
if (Constr === AbstractType) {
// @ts-ignore
const t = new TypeConstructor()
t._map = type._map
type._map.forEach(/** @param {Item?} n */ n => {
for (; n !== null; n = n.left) {
n.parent = t
}
})
t._start = type._start
for (let n = t._start; n !== null; n = n.right) {
n.parent = t
}
t._length = type._length
this.share.set(name, t)
t._integrate(this, null)
return t
} else {
throw new Error(`Type with the name ${name} has already been defined with a different constructor`)
}
}
return type
}
/**
* @template T
* @param {string} name
* @return {YArray<T>}
*
* @public
*/
getArray (name) {
// @ts-ignore
return this.get(name, YArray)
}
/**
* @param {string} name
* @return {YText}
*
* @public
*/
getText (name) {
// @ts-ignore
return this.get(name, YText)
}
/**
* @param {string} name
* @return {YMap<any>}
*
* @public
*/
getMap (name) {
// @ts-ignore
return this.get(name, YMap)
}
/**
* @param {string} name
* @return {YXmlFragment}
*
* @public
*/
getXmlFragment (name) {
// @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
*/
on (eventName, f) {
super.on(eventName, f)
}
/**
* @param {string} eventName
* @param {function} f
*/
off (eventName, f) {
super.off(eventName, f)
}
}

82
src/utils/EventHandler.js Normal file
View File

@@ -0,0 +1,82 @@
import * as f from 'lib0/function.js'
/**
* General event handler implementation.
*
* @template ARG0, ARG1
*
* @private
*/
export class EventHandler {
constructor () {
/**
* @type {Array<function(ARG0, ARG1):void>}
*/
this.l = []
}
}
/**
* @template ARG0,ARG1
* @returns {EventHandler<ARG0,ARG1>}
*
* @private
* @function
*/
export const createEventHandler = () => new EventHandler()
/**
* Adds an event listener that is called when
* {@link EventHandler#callEventListeners} is called.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {function(ARG0,ARG1):void} f The event handler.
*
* @private
* @function
*/
export const addEventHandlerListener = (eventHandler, f) =>
eventHandler.l.push(f)
/**
* Removes an event listener.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {function(ARG0,ARG1):void} f The event handler that was added with
* {@link EventHandler#addEventListener}
*
* @private
* @function
*/
export const removeEventHandlerListener = (eventHandler, f) => {
eventHandler.l = eventHandler.l.filter(g => f !== g)
}
/**
* Removes all event listeners.
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
*
* @private
* @function
*/
export const removeAllEventHandlerListeners = eventHandler => {
eventHandler.l.length = 0
}
/**
* Call all event listeners that were added via
* {@link EventHandler#addEventListener}.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {ARG0} arg0
* @param {ARG1} arg1
*
* @private
* @function
*/
export const callEventHandlerListeners = (eventHandler, arg0, arg1) =>
f.callAll(eventHandler.l, [arg0, arg1])

90
src/utils/ID.js Normal file
View File

@@ -0,0 +1,90 @@
import { AbstractType } from '../internals.js' // eslint-disable-line
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import * as error from 'lib0/error.js'
export class ID {
/**
* @param {number} client client id
* @param {number} clock unique per client id, continuous number
*/
constructor (client, clock) {
/**
* Client id
* @type {number}
*/
this.client = client
/**
* unique per client id, continuous number
* @type {number}
*/
this.clock = clock
}
}
/**
* @param {ID | null} a
* @param {ID | null} b
* @return {boolean}
*
* @function
*/
export const compareIDs = (a, b) => a === b || (a !== null && b !== null && a.client === b.client && a.clock === b.clock)
/**
* @param {number} client
* @param {number} clock
*
* @private
* @function
*/
export const createID = (client, clock) => new ID(client, clock)
/**
* @param {encoding.Encoder} encoder
* @param {ID} id
*
* @private
* @function
*/
export const writeID = (encoder, id) => {
encoding.writeVarUint(encoder, id.client)
encoding.writeVarUint(encoder, id.clock)
}
/**
* Read ID.
* * If first varUint read is 0xFFFFFF a RootID is returned.
* * Otherwise an ID is returned
*
* @param {decoding.Decoder} decoder
* @return {ID}
*
* @private
* @function
*/
export const readID = decoder =>
createID(decoding.readVarUint(decoder), decoding.readVarUint(decoder))
/**
* The top types are mapped from y.share.get(keyname) => type.
* `type` does not store any information about the `keyname`.
* This function finds the correct `keyname` for `type` and throws otherwise.
*
* @param {AbstractType<any>} type
* @return {string}
*
* @private
* @function
*/
export const findRootTypeKey = type => {
// @ts-ignore _y must be defined, otherwise unexpected case
for (const [key, value] of type.doc.share) {
if (value === type) {
return key
}
}
throw error.unexpectedCase()
}

View File

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

View File

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

140
src/utils/Snapshot.js Normal file
View File

@@ -0,0 +1,140 @@
import {
isDeleted,
createDeleteSetFromStructStore,
getStateVector,
getItemCleanStart,
createID,
iterateDeletedStructs,
writeDeleteSet,
writeStateVector,
readDeleteSet,
readStateVector,
createDeleteSet,
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>} sv state map
*/
constructor (ds, sv) {
/**
* @type {DeleteSet}
* @private
*/
this.ds = ds
/**
* State Map
* @type {Map<number,number>}
* @private
*/
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
*
* @protected
* @function
*/
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
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)
}
}

275
src/utils/StructStore.js Normal file
View File

@@ -0,0 +1,275 @@
import {
GC,
splitItem,
GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line
} from '../internals.js'
import * as math from 'lib0/math.js'
import * as error from 'lib0/error.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
export class StructStore {
constructor () {
/**
* @type {Map<number,Array<GC|Item>>}
* @private
*/
this.clients = new Map()
/**
* Store incompleted struct reads here
* `i` denotes to the next read operation
* 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
*/
this.pendingClientsStructRefs = new Map()
/**
* Stack of pending structs waiting for struct dependencies
* Maximum length of stack is structReaders.size
* @type {Array<GCRef|ItemRef>}
* @private
*/
this.pendingStack = []
/**
* @type {Array<decoding.Decoder>}
* @private
*/
this.pendingDeleteReaders = []
}
}
/**
* Return the states as a Map<client,clock>.
* Note that clock refers to the next expected clock id.
*
* @param {StructStore} store
* @return {Map<number,number>}
*
* @public
* @function
*/
export const getStateVector = store => {
const sm = new Map()
store.clients.forEach((structs, client) => {
const struct = structs[structs.length - 1]
sm.set(client, struct.id.clock + struct.length)
})
return sm
}
/**
* @param {StructStore} store
* @param {number} client
* @return {number}
*
* @public
* @function
*/
export const getState = (store, client) => {
const structs = store.clients.get(client)
if (structs === undefined) {
return 0
}
const lastStruct = structs[structs.length - 1]
return lastStruct.id.clock + lastStruct.length
}
/**
* @param {StructStore} store
*
* @private
* @function
*/
export const integretyCheck = store => {
store.clients.forEach(structs => {
for (let i = 1; i < structs.length; i++) {
const l = structs[i - 1]
const r = structs[i]
if (l.id.clock + l.length !== r.id.clock) {
throw new Error('StructStore failed integrety check')
}
}
})
}
/**
* @param {StructStore} store
* @param {GC|Item} struct
*
* @private
* @function
*/
export const addStruct = (store, struct) => {
let structs = store.clients.get(struct.id.client)
if (structs === undefined) {
structs = []
store.clients.set(struct.id.client, structs)
} else {
const lastStruct = structs[structs.length - 1]
if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) {
throw error.unexpectedCase()
}
}
structs.push(struct)
}
/**
* Perform a binary search on a sorted array
* @param {Array<any>} structs
* @param {number} clock
* @return {number}
*
* @private
* @function
*/
export const findIndexSS = (structs, clock) => {
let left = 0
let right = structs.length - 1
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = structs[midindex]
const midclock = mid.id.clock
if (midclock <= clock) {
if (clock < midclock + mid.length) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
}
// Always check state before looking for a struct in StructStore
// Therefore the case of not finding a struct is unexpected
throw error.unexpectedCase()
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {StructStore} store
* @param {ID} id
* @return {GC|Item}
*
* @private
* @function
*/
export const find = (store, id) => {
/**
* @type {Array<GC|Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
return structs[findIndexSS(structs, id.clock)]
}
/**
* 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)
/**
* @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 {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanStart = (transaction, id) => {
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
return structs[findIndexCleanStart(transaction, structs, id.clock)]
}
/**
* 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 getItemCleanEnd = (transaction, store, id) => {
/**
* @type {Array<Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
const index = findIndexSS(structs, id.clock)
const struct = structs[index]
if (id.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) {
structs.splice(index + 1, 0, splitItem(transaction, struct, id.clock - struct.id.clock + 1))
}
return struct
}
/**
* Replace `item` with `newitem` in store
* @param {StructStore} store
* @param {GC|Item} struct
* @param {GC|Item} newStruct
*
* @private
* @function
*/
export const replaceStruct = (store, struct, newStruct) => {
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)
}

367
src/utils/Transaction.js Normal file
View File

@@ -0,0 +1,367 @@
import {
getState,
createID,
writeStructsFromTransaction,
writeDeleteSet,
DeleteSet,
sortAndMergeDeleteSet,
getStateVector,
findIndexSS,
callEventHandlerListeners,
Item,
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 { callAll } from 'lib0/function.js'
/**
* A transaction is created for every change on the Yjs model. It is possible
* to bundle changes on the Yjs model in a single transaction to
* minimize the number on messages sent and the number of observer calls.
* If possible the user of this library should bundle as many changes as
* possible. Here is an example to illustrate the advantages of bundling:
*
* @example
* const map = y.define('map', YMap)
* // Log content when change is triggered
* map.observe(() => {
* console.log('change triggered')
* })
* // Each change on the map type triggers a log message:
* map.set('a', 0) // => "change triggered"
* map.set('b', 0) // => "change triggered"
* // When put in a transaction, it will trigger the log after the transaction:
* y.transact(() => {
* map.set('a', 1)
* map.set('b', 1)
* }) // => "change triggered"
*
* @public
*/
export class Transaction {
/**
* @param {Doc} doc
* @param {any} origin
* @param {boolean} local
*/
constructor (doc, origin, local) {
/**
* The Yjs instance.
* @type {Doc}
*/
this.doc = doc
/**
* Describes the set of deleted items by ids
* @type {DeleteSet}
*/
this.deleteSet = new DeleteSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>}
*/
this.beforeState = getStateVector(doc.store)
/**
* Holds the state after the transaction.
* @type {Map<Number,Number>}
*/
this.afterState = new Map()
/**
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
*/
this.changed = new Map()
/**
* Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`.
* @type {Map<AbstractType<YEvent>,Array<YEvent>>}
*/
this.changedParentTypes = new Map()
/**
* @type {Set<ID>}
* @private
*/
this._mergeStructs = new Set()
/**
* @type {any}
*/
this.origin = origin
/**
* Stores meta information on the transaction
* @type {Map<any,any>}
*/
this.meta = new Map()
/**
* Whether this change originates from this doc.
* @type {boolean}
*/
this.local = local
}
}
/**
* @param {Transaction} transaction
*/
export const computeUpdateMessageFromTransaction = transaction => {
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
return null
}
const encoder = encoding.createEncoder()
sortAndMergeDeleteSet(transaction.deleteSet)
writeStructsFromTransaction(encoder, transaction)
writeDeleteSet(encoder, transaction.deleteSet)
return encoder
}
/**
* @param {Transaction} transaction
*
* @private
* @function
*/
export const nextID = transaction => {
const y = transaction.doc
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 && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
const tryGcDeleteSet = (ds, store, gcFilter) => {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
struct.gc(store, false)
}
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
*/
const tryMergeDeleteSet = (ds, store) => {
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (ds, store, gcFilter) => {
tryGcDeleteSet(ds, store, gcFilter)
tryMergeDeleteSet(ds, store)
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
try {
sortAndMergeDeleteSet(ds)
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<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])
}
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
} else {
cleanupTransactions(transactionCleanups, i + 1)
}
}
}
}
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @param {Doc} doc
* @param {function(Transaction):void} f
* @param {any} [origin=true]
*
* @function
*/
export const transact = (doc, f, origin = null, local = true) => {
const transactionCleanups = doc._transactionCleanups
let initialCall = false
if (doc._transaction === null) {
initialCall = true
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
doc.emit('beforeTransaction', [doc._transaction, doc])
}
try {
f(doc._transaction)
} finally {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
// Observer call may create new transactions for which we need to call the observers and do cleanup.
// We don't want to nest these calls, so we execute these calls one after
// another.
// Also we need to ensure that all cleanups are called, even if the
// observes throw errors.
// This file is full of hacky try {} finally {} blocks to ensure that an
// event can throw errors and also that the cleanup is called.
cleanupTransactions(transactionCleanups, 0)
}
}
}

283
src/utils/UndoManager.js Normal file
View File

@@ -0,0 +1,283 @@
import {
mergeDeleteSets,
iterateDeletedStructs,
keepItem,
transact,
redoItem,
iterateStructs,
isParentOf,
createID,
followRedone,
getItemCleanStart,
getState,
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js'
import * as time from 'lib0/time.js'
import { Observable } from 'lib0/observable.js'
class StackItem {
/**
* @param {DeleteSet} ds
* @param {number} start clock start of the local client
* @param {number} len
*/
constructor (ds, start, len) {
this.ds = ds
this.start = start
this.len = len
/**
* 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 clientID = doc.clientID
const stackItem = /** @type {StackItem} */ (stack.pop())
const stackStartClock = stackItem.start
const stackEndClock = stackItem.start + stackItem.len
const itemsToRedo = new Set()
// @todo iterateStructs should not need the structs parameter
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientID))
let performedChange = false
if (stackStartClock !== stackEndClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
getItemCleanStart(transaction, createID(clientID, stackStartClock))
if (stackEndClock < getState(doc.store, clientID)) {
getItemCleanStart(transaction, createID(clientID, stackEndClock))
}
}
iterateDeletedStructs(transaction, stackItem.ds, struct => {
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(struct.id.client === clientID && struct.id.clock >= stackStartClock && struct.id.clock < stackEndClock)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
})
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
iterateStructs(transaction, structs, stackStartClock, stackItem.len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > stackItem.len) {
getItemCleanStart(transaction, createID(item.id.client, stackEndClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
const item = itemsToDelete[i]
if (undoManager.deleteFilter(item)) {
item.delete(transaction)
performedChange = true
}
}
result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
}
}, undoManager)
return result
}
/**
* @typedef {Object} UndoManagerOptions
* @property {number} [UndoManagerOptions.captureTimeout=500]
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
* it is necessary to filter whan an Undo/Redo operation can delete. If this
* filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
*/
/**
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
* the redo-stack. You may store additional stack information via the
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
* Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
*
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
*/
export class UndoManager extends Observable {
/**
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {UndoManagerOptions} options
*/
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
this.deleteFilter = deleteFilter
trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins
/**
* @type {Array<StackItem>}
*/
this.undoStack = []
/**
* @type {Array<StackItem>}
*/
this.redoStack = []
/**
* Whether the client is currently undoing (calling UndoManager.undo)
*
* @type {boolean}
*/
this.undoing = false
this.redoing = false
this.doc = /** @type {Doc} */ (this.scope[0].doc)
this.lastChange = 0
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
// Only track certain transactions
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
return
}
const undoing = this.undoing
const redoing = this.redoing
const stack = undoing ? this.redoStack : this.undoStack
if (undoing) {
this.stopCapturing() // next undo should not be appended to last stack item
} else if (!redoing) {
// neither undoing nor redoing: delete redoStack
this.redoStack = []
}
const beforeState = transaction.beforeState.get(this.doc.clientID) || 0
const afterState = transaction.afterState.get(this.doc.clientID) || 0
const now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.len = afterState - lastOp.start
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState))
}
if (!undoing && !redoing) {
this.lastChange = now
}
// make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, true)
}
})
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
})
}
clear () {
this.doc.transact(transaction => {
/**
* @param {StackItem} stackItem
*/
const clearItem = stackItem => {
iterateDeletedStructs(transaction, stackItem.ds, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
this.undoStack.forEach(clearItem)
this.redoStack.forEach(clearItem)
})
this.undoStack = []
this.redoStack = []
}
/**
* UndoManager merges Undo-StackItem if they are created within time-gap
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
* StackItem won't be merged.
*
*
* @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
let res
try {
res = popStackItem(this, this.undoStack, 'undo')
} finally {
this.undoing = false
}
return res
}
/**
* Redo last undo operation.
*
* @return {StackItem?} Returns StackItem if a change was applied
*/
redo () {
this.redoing = true
let res
try {
res = popStackItem(this, this.redoStack, 'redo')
} finally {
this.redoing = false
}
return res
}
}

226
src/utils/YEvent.js Normal file
View File

@@ -0,0 +1,226 @@
import {
isDeleted,
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.
*/
export class YEvent {
/**
* @param {AbstractType<any>} target The changed type.
* @param {Transaction} transaction
*/
constructor (target, transaction) {
/**
* The type on which this event was created on.
* @type {AbstractType<any>}
*/
this.target = target
/**
* The current target on which the observe callback is called.
* @type {AbstractType<any>}
*/
this.currentTarget = target
/**
* The transaction that triggered this event.
* @type {Transaction}
*/
this.transaction = transaction
/**
* @type {Object|null}
*/
this._changes = null
}
/**
* Computes the path from `y` to the changed type.
*
* The following property holds:
* @example
* let type = y
* event.path.forEach(dir => {
* type = type.get(dir)
* })
* type === event.target // => true
*/
get path () {
// @ts-ignore _item is defined because target is integrated
return getPathTo(this.currentTarget, this.target)
}
/**
* 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}
*/
deletes (struct) {
return isDeleted(this.transaction.deleteSet, struct.id)
}
/**
* 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)
}
}
/**
* Compute the path from this type to the specified target.
*
* @example
* // `child` should be accessible via `type.get(path[0]).get(path[1])..`
* const path = type.getPathTo(child)
* // assuming `type instanceof YArray`
* console.log(path) // might look like => [2, 'key1']
* child === type.get(path[0]).get(path[1])
*
* @param {AbstractType<any>} parent
* @param {AbstractType<any>} child target
* @return {Array<string|number>} Path to the target
*
* @private
* @function
*/
const getPathTo = (parent, child) => {
const path = []
while (child._item !== null && child !== parent) {
if (child._item.parentSub !== null) {
// parent is map-ish
path.unshift(child._item.parentSub)
} else {
// parent is array-ish
let i = 0
let c = child._item.parent._start
while (c !== child._item && c !== null) {
if (!c.deleted) {
i++
}
c = c.right
}
path.unshift(i)
}
child = child._item.parent
}
return path
}

423
src/utils/encoding.js Normal file
View File

@@ -0,0 +1,423 @@
/**
* @module encoding
*
* We use the first five bits in the info flag for determining the type of the struct.
*
* 0: GC
* 1: Item with Deleted content
* 2: Item with JSON content
* 3: Item with Binary content
* 4: Item with String content
* 5: Item with Embed content (for richtext content)
* 6: Item with Format content (a formatting marker for richtext content)
* 7: Item with Type
*/
import {
findIndexSS,
GCRef,
ItemRef,
writeID,
createID,
readID,
getState,
getStateVector,
readAndApplyDeleteSet,
writeDeleteSet,
createDeleteSetFromStructStore,
transact,
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as binary from 'lib0/binary.js'
/**
* @param {encoding.Encoder} encoder
* @param {Array<AbstractStruct>} structs All structs by `client`
* @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)`
*
* @function
*/
const writeStructs = (encoder, structs, client, clock) => {
// write first id
const startNewStructs = findIndexSS(structs, clock)
// write # encoded structs
encoding.writeVarUint(encoder, structs.length - startNewStructs)
writeID(encoder, createID(client, clock))
const firstStruct = structs[startNewStructs]
// write first struct with an offset
firstStruct.write(encoder, clock - firstStruct.id.clock, 0)
for (let i = startNewStructs + 1; i < structs.length; i++) {
structs[i].write(encoder, 0, 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
* @param {Map<number,number>} _sm
*
* @private
* @function
*/
export const writeClientsStructs = (encoder, store, _sm) => {
// we filter all valid _sm entries into sm
const sm = new Map()
_sm.forEach((clock, client) => {
// only write if new structs are available
if (getState(store, client) > clock) {
sm.set(client, clock)
}
})
getStateVector(store).forEach((clock, client) => {
if (!_sm.has(client)) {
sm.set(client, 0)
}
})
// write # states that were updated
encoding.writeVarUint(encoder, sm.size)
sm.forEach((clock, client) => {
// @ts-ignore
writeStructs(encoder, store.clients.get(client), client, clock)
})
}
/**
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @return {Map<number,Array<GCRef|ItemRef>>}
*
* @private
* @function
*/
export const readClientsStructRefs = decoder => {
/**
* @type {Map<number,Array<GCRef|ItemRef>>}
*/
const clientRefs = new Map()
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)
}
return clientRefs
}
/**
* Resume computing structs generated by struct readers.
*
* While there is something to do, we integrate structs in this order
* 1. top element on stack, if stack is not empty
* 2. next element from current struct reader (if empty, use next struct reader)
*
* If struct causally depends on another struct (ref.missing), we put next reader of
* `ref.id.client` on top of stack.
*
* At some point we find a struct that has no causal dependencies,
* then we start emptying the stack.
*
* It is not possible to have circles: i.e. struct1 (from client1) depends on struct2 (from client2)
* depends on struct3 (from client1). Therefore the max stack size is eqaul to `structReaders.length`.
*
* This method is implemented in a way so that we can resume computation if this update
* causally depends on another update.
*
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
* @function
*/
const resumeStructIntegration = (transaction, store) => {
const stack = store.pendingStack
const clientsStructRefs = store.pendingClientsStructRefs
// iterate over all struct readers until we are done
while (stack.length !== 0 || clientsStructRefs.size !== 0) {
if (stack.length === 0) {
// take any first struct from clientsStructRefs and put it on the stack
const [client, structRefs] = clientsStructRefs.entries().next().value
stack.push(structRefs.refs[structRefs.i++])
if (structRefs.refs.length === structRefs.i) {
clientsStructRefs.delete(client)
}
}
const ref = stack[stack.length - 1]
const m = ref._missing
const client = ref.id.client
const localClock = getState(store, client)
const offset = ref.id.clock < localClock ? localClock - ref.id.clock : 0
if (ref.id.clock + offset !== localClock) {
// 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) {
// put ref with smaller clock on stack instead and continue
structRefs.refs[structRefs.i] = ref
stack[stack.length - 1] = r
// sort the set because this approach might bring the list out of order
structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.clock - r2.id.clock)
structRefs.i = 0
continue
}
}
// 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
}
ref._missing.pop()
}
if (m.length === 0) {
if (offset < ref.length) {
ref.toStruct(transaction, store, offset).integrate(transaction)
}
stack.pop()
}
}
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
* @function
*/
export const tryResumePendingDeleteReaders = (transaction, store) => {
const pendingReaders = store.pendingDeleteReaders
store.pendingDeleteReaders = []
for (let i = 0; i < pendingReaders.length; i++) {
readAndApplyDeleteSet(pendingReaders[i], transaction, store)
}
}
/**
* @param {encoding.Encoder} encoder
* @param {Transaction} transaction
*
* @private
* @function
*/
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
/**
* @param {StructStore} store
* @param {Map<number, Array<GCRef|ItemRef>>} clientsStructsRefs
*
* @private
* @function
*/
const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
const pendingClientsStructRefs = store.pendingClientsStructRefs
for (const [client, structRefs] of clientsStructsRefs) {
const pendingStructRefs = pendingClientsStructRefs.get(client)
if (pendingStructRefs === undefined) {
pendingClientsStructRefs.set(client, { refs: structRefs, i: 0 })
} else {
// merge into existing structRefs
const merged = pendingStructRefs.i > 0 ? pendingStructRefs.refs.slice(pendingStructRefs.i) : pendingStructRefs.refs
for (let i = 0; i < structRefs.length; i++) {
merged.push(structRefs[i])
}
pendingStructRefs.i = 0
pendingStructRefs.refs = merged.sort((r1, r2) => r1.id.clock - r2.id.clock)
}
}
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
* @function
*/
export const readStructs = (decoder, transaction, store) => {
const clientsStructRefs = readClientsStructRefs(decoder)
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
resumeStructIntegration(transaction, store)
tryResumePendingDeleteReaders(transaction, store)
}
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts an decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
transact(ydoc, transaction => {
readStructs(decoder, transaction, ydoc.store)
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin, false)
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
*
* @param {Doc} ydoc
* @param {Uint8Array} update
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const applyUpdate = (ydoc, update, transactionOrigin) =>
readUpdate(decoding.createDecoder(update), ydoc, transactionOrigin)
/**
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
* only write the operations that are missing.
*
* @param {encoding.Encoder} encoder
* @param {Doc} doc
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
*
* @function
*/
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
writeClientsStructs(encoder, doc.store, targetStateVector)
writeDeleteSet(encoder, createDeleteSetFromStructStore(doc.store))
}
/**
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
* only write the operations that are missing.
*
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => {
const encoder = encoding.createEncoder()
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
writeStateAsUpdate(encoder, doc, targetStateVector)
return encoding.toUint8Array(encoder)
}
/**
* Read state vector from Decoder and return as Map
*
* @param {decoding.Decoder} decoder
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const readStateVector = decoder => {
const ss = new Map()
const ssLength = decoding.readVarUint(decoder)
for (let i = 0; i < ssLength; i++) {
const client = decoding.readVarUint(decoder)
const clock = decoding.readVarUint(decoder)
ss.set(client, clock)
}
return ss
}
/**
* Read decodedState and return State as Map.
*
* @param {Uint8Array} decodedState
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Map<number,number>} sv
* @function
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder, sv.size)
sv.forEach((clock, client) => {
encoding.writeVarUint(encoder, client)
encoding.writeVarUint(encoder, clock)
})
return encoder
}
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Doc} doc
*
* @function
*/
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
/**
* Encode State as Uint8Array.
*
* @param {Doc} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVector = doc => {
const encoder = encoding.createEncoder()
writeDocumentStateVector(encoder, doc)
return encoding.toUint8Array(encoder)
}

22
src/utils/isParentOf.js Normal file
View File

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

237
src/y.js
View File

@@ -1,237 +0,0 @@
import debug from 'debug'
import extendConnector from './Connector.js'
import extendDatabase from './Database.js'
import extendTransaction from './Transaction.js'
import extendStruct from './Struct.js'
import extendUtils from './Utils.js'
extendConnector(Y)
extendDatabase(Y)
extendTransaction(Y)
extendStruct(Y)
extendUtils(Y)
Y.debug = debug
var requiringModules = {}
Y.requiringModules = requiringModules
Y.extend = function (name, value) {
if (arguments.length === 2 && typeof name === 'string') {
if (value instanceof Y.utils.CustomTypeDefinition) {
Y[name] = value.parseArguments
} else {
Y[name] = value
}
if (requiringModules[name] != null) {
requiringModules[name].resolve()
delete requiringModules[name]
}
} else {
for (var i = 0; i < arguments.length; i++) {
var f = arguments[i]
if (typeof f === 'function') {
f(Y)
} else {
throw new Error('Expected function!')
}
}
}
}
Y.requestModules = requestModules
function requestModules (modules) {
var sourceDir
if (Y.sourceDir === null) {
sourceDir = null
} else {
sourceDir = Y.sourceDir || '/bower_components'
}
// determine if this module was compiled for es5 or es6 (y.js vs. y.es6)
// if Insert.execute is a Function, then it isnt a generator..
// then load the es5(.js) files..
var extention = typeof regeneratorRuntime !== 'undefined' ? '.js' : '.es6'
var promises = []
for (var i = 0; i < modules.length; i++) {
var module = modules[i].split('(')[0]
var modulename = 'y-' + module.toLowerCase()
if (Y[module] == null) {
if (requiringModules[module] == null) {
// module does not exist
if (typeof window !== 'undefined' && window.Y !== 'undefined') {
if (sourceDir != null) {
var imported = document.createElement('script')
imported.src = sourceDir + '/' + modulename + '/' + modulename + extention
document.head.appendChild(imported)
}
let requireModule = {}
requiringModules[module] = requireModule
requireModule.promise = new Promise(function (resolve) {
requireModule.resolve = resolve
})
promises.push(requireModule.promise)
} else {
console.info('YJS: Please do not depend on automatic requiring of modules anymore! Extend modules as follows `require(\'y-modulename\')(Y)`')
require(modulename)(Y)
}
} else {
promises.push(requiringModules[modules[i]].promise)
}
}
}
return Promise.all(promises)
}
/* ::
type MemoryOptions = {
name: 'memory'
}
type IndexedDBOptions = {
name: 'indexeddb',
namespace: string
}
type DbOptions = MemoryOptions | IndexedDBOptions
type WebRTCOptions = {
name: 'webrtc',
room: string
}
type WebsocketsClientOptions = {
name: 'websockets-client',
room: string
}
type ConnectionOptions = WebRTCOptions | WebsocketsClientOptions
type YOptions = {
connector: ConnectionOptions,
db: DbOptions,
types: Array<TypeName>,
sourceDir: string,
share: {[key: string]: TypeName}
}
*/
export default function Y (opts/* :YOptions */) /* :Promise<YConfig> */ {
if (opts.hasOwnProperty('sourceDir')) {
Y.sourceDir = opts.sourceDir
}
opts.types = opts.types != null ? opts.types : []
var modules = [opts.db.name, opts.connector.name].concat(opts.types)
for (var name in opts.share) {
modules.push(opts.share[name])
}
return new Promise(function (resolve, reject) {
if (opts == null) reject(new Error('An options object is expected!'))
else if (opts.connector == null) reject(new Error('You must specify a connector! (missing connector property)'))
else if (opts.connector.name == null) reject(new Error('You must specify connector name! (missing connector.name property)'))
else if (opts.db == null) reject(new Error('You must specify a database! (missing db property)'))
else if (opts.connector.name == null) reject(new Error('You must specify db name! (missing db.name property)'))
else {
opts = Y.utils.copyObject(opts)
opts.connector = Y.utils.copyObject(opts.connector)
opts.db = Y.utils.copyObject(opts.db)
opts.share = Y.utils.copyObject(opts.share)
Y.requestModules(modules).then(function () {
var yconfig = new YConfig(opts)
yconfig.db.whenUserIdSet(function () {
yconfig.init(function () {
resolve(yconfig)
})
})
}).catch(reject)
}
})
}
class YConfig {
/* ::
db: Y.AbstractDatabase;
connector: Y.AbstractConnector;
share: {[key: string]: any};
options: Object;
*/
constructor (opts, callback) {
this.options = opts
this.db = new Y[opts.db.name](this, opts.db)
this.connector = new Y[opts.connector.name](this, opts.connector)
this.connected = true
}
init (callback) {
var opts = this.options
var share = {}
this.share = share
this.db.requestTransaction(function * requestTransaction () {
// create shared object
for (var propertyname in opts.share) {
var typeConstructor = opts.share[propertyname].split('(')
var typeName = typeConstructor.splice(0, 1)
var type = Y[typeName]
var typedef = type.typeDefinition
var id = ['_', typedef.struct + '_' + typeName + '_' + propertyname + '_' + typeConstructor]
var args = []
if (typeConstructor.length === 1) {
try {
args = JSON.parse('[' + typeConstructor[0].split(')')[0] + ']')
} catch (e) {
throw new Error('Was not able to parse type definition! (share.' + propertyname + ')')
}
if (type.typeDefinition.parseArguments == null) {
throw new Error(typeName + ' does not expect arguments!')
} else {
args = typedef.parseArguments(args[0])[1]
}
}
share[propertyname] = yield * this.store.initType.call(this, id, args)
}
this.store.whenTransactionsFinished()
.then(callback)
})
}
isConnected () {
return this.connector.isSynced
}
disconnect () {
if (this.connected) {
this.connected = false
return this.connector.disconnect()
} else {
return Promise.resolve()
}
}
reconnect () {
if (!this.connected) {
this.connected = true
return this.connector.reconnect()
} else {
return Promise.resolve()
}
}
destroy () {
var self = this
return this.close().then(function () {
if (self.db.deleteDB != null) {
return self.db.deleteDB()
} else {
return Promise.resolve()
}
})
}
close () {
var self = this
this.share = null
if (this.connector.destroy != null) {
this.connector.destroy()
} else {
this.connector.disconnect()
}
return this.db.whenTransactionsFinished().then(function () {
self.db.destroyTypes()
// make sure to wait for all transactions before destroying the db
self.db.requestTransaction(function * () {
yield * self.db.destroy()
})
return self.db.whenTransactionsFinished()
})
}
}

9
test.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Testing Yjs</title>
</head>
<body>
<script type="module" src="./dist/tests.js"></script>
</body>
</html>

View File

@@ -1,178 +0,0 @@
import _Y from '../../yjs/src/y.js'
import yMemory from '../../y-memory/src/Memory.js'
import yArray from '../../y-array/src/y-array.js'
import yMap from '../../y-map/src/Map.js'
import yTest from './test-connector.js'
import Chance from 'chance'
export let Y = _Y
Y.extend(yMemory, yArray, yMap, yTest)
export async function garbageCollectUsers (t, users) {
await flushAll(t, users)
await Promise.all(users.map(u => u.db.emptyGarbageCollector()))
}
/*
* 1. reconnect and flush all
* 2. user 0 gc
* 3. get type content
* 4. disconnect & reconnect all (so gc is propagated)
* 5. compare os, ds, ss
*/
export async function compareUsers (t, users) {
await Promise.all(users.map(u => u.reconnect()))
if (users[0].connector.testRoom == null) {
await wait(100)
}
await flushAll(t, users)
await users[0].db.garbageCollect()
await users[0].db.garbageCollect()
var userTypeContents = users.map(u => u.share.array._content.map(c => c.val || JSON.stringify(c.type)))
// disconnect all except user 0
await Promise.all(users.slice(1).map(async u =>
u.disconnect()
))
if (users[0].connector.testRoom == null) {
await wait(100)
}
// reconnect all
await Promise.all(users.map(u => u.reconnect()))
if (users[0].connector.testRoom == null) {
await wait(100)
}
await users[0].connector.testRoom.flushAll(users)
await Promise.all(users.map(u =>
new Promise(function (resolve) {
u.connector.whenSynced(resolve)
})
))
var data = await Promise.all(users.map(async (u) => {
var data = {}
u.db.requestTransaction(function * () {
var os = yield * this.getOperationsUntransformed()
data.os = {}
os.untransformed.forEach((op) => {
op = Y.Struct[op.struct].encode(op)
delete op.origin
data.os[JSON.stringify(op.id)] = op
return op
})
data.ds = yield * this.getDeleteSet()
data.ss = yield * this.getStateSet()
})
await u.db.whenTransactionsFinished()
return data
}))
for (var i = 0; i < data.length - 1; i++) {
await t.asyncGroup(async () => {
t.compare(userTypeContents[i], userTypeContents[i + 1], 'types')
t.compare(data[i].os, data[i + 1].os, 'os')
t.compare(data[i].ds, data[i + 1].ds, 'ds')
t.compare(data[i].ss, data[i + 1].ss, 'ss')
}, `Compare user${i} with user${i + 1}`)
}
await Promise.all(users.map(async (u) => {
await u.close()
}))
}
export async function initArrays (t, opts) {
var result = {
users: []
}
var share = Object.assign({ flushHelper: 'Map', array: 'Array' }, opts.share)
var chance = opts.chance || new Chance(t.getSeed() * 1000000000)
var connector = Object.assign({ room: 'debugging_' + t.name, testContext: t, chance }, opts.connector)
for (let i = 0; i < opts.users; i++) {
let dbOpts
let connOpts
if (i === 0) {
// Only one instance can gc!
dbOpts = Object.assign({ gc: true }, opts.db)
connOpts = Object.assign({ role: 'master' }, connector)
} else {
dbOpts = Object.assign({ gc: false }, opts.db)
connOpts = Object.assign({ role: 'slave' }, connector)
}
let y = await Y({
connector: connOpts,
db: dbOpts,
share: share
})
result.users.push(y)
for (let name in share) {
result[name + i] = y.share[name]
}
}
result.array0.delete(0, result.array0.length)
if (result.users[0].connector.testRoom != null) {
// flush for sync if test-connector
await result.users[0].connector.testRoom.flushAll(result.users)
}
await Promise.all(result.users.map(u => {
return new Promise(function (resolve) {
u.connector.whenSynced(resolve)
})
}))
await flushAll(t, result.users)
return result
}
export async function flushAll (t, users) {
// users = users.filter(u => u.connector.isSynced)
if (users.length === 0) {
return
}
await wait(0)
if (users[0].connector.testRoom != null) {
// use flushAll method specified in Test Connector
await users[0].connector.testRoom.flushAll(users)
} else {
// flush for any connector
await Promise.all(users.map(u => { return u.db.whenTransactionsFinished() }))
var flushCounter = users[0].share.flushHelper.get('0') || 0
flushCounter++
await Promise.all(users.map(async (u, i) => {
// wait for all users to set the flush counter to the same value
await new Promise(resolve => {
function observer () {
var allUsersReceivedUpdate = true
for (var i = 0; i < users.length; i++) {
if (u.share.flushHelper.get(i + '') !== flushCounter) {
allUsersReceivedUpdate = false
break
}
}
if (allUsersReceivedUpdate) {
resolve()
}
}
u.share.flushHelper.observe(observer)
u.share.flushHelper.set(i + '', flushCounter)
})
}))
}
}
export async function flushSome (t, users) {
if (users[0].connector.testRoom == null) {
// if not test-connector, wait for some time for operations to arrive
await wait(100)
}
}
export function wait (t) {
return new Promise(function (resolve) {
setTimeout(resolve, t != null ? t : 100)
})
}

View File

@@ -1,151 +0,0 @@
/* global Y */
import { wait } from './helper.js'
var rooms = {}
export class TestRoom {
constructor (roomname) {
this.room = roomname
this.users = {}
this.nextUserId = 0
}
join (connector) {
if (connector.userId == null) {
connector.setUserId('' + (this.nextUserId++))
}
Object.keys(this.users).forEach(uid => {
this.users[uid].userJoined(connector.userId, connector.role)
connector.userJoined(uid, this.users[uid].role)
})
this.users[connector.userId] = connector
}
leave (connector) {
delete this.users[connector.userId]
Object.keys(this.users).forEach(uid => {
this.users[uid].userLeft(connector.userId)
})
}
send (sender, receiver, m) {
m = JSON.parse(JSON.stringify(m))
var user = this.users[receiver]
if (user != null) {
user.receiveMessage(sender, m)
}
}
broadcast (sender, m) {
Object.keys(this.users).forEach(receiver => {
this.send(sender, receiver, m)
})
}
async flushAll (users) {
let flushing = true
let allUserIds = Object.keys(this.users)
if (users == null) {
users = allUserIds.map(id => this.users[id].y)
}
while (flushing) {
await wait(10)
let res = await Promise.all(allUserIds.map(id => this.users[id]._flushAll(users)))
flushing = res.some(status => status === 'flushing')
}
}
}
function getTestRoom (roomname) {
if (rooms[roomname] == null) {
rooms[roomname] = new TestRoom(roomname)
}
return rooms[roomname]
}
export default function extendTestConnector (Y) {
class TestConnector extends Y.AbstractConnector {
constructor (y, options) {
if (options === undefined) {
throw new Error('Options must not be undefined!')
}
if (options.room == null) {
throw new Error('You must define a room name!')
}
super(y, options)
this.options = options
this.room = options.room
this.chance = options.chance
this.testRoom = getTestRoom(this.room)
this.testRoom.join(this)
}
disconnect () {
this.testRoom.leave(this)
return super.disconnect()
}
reconnect () {
this.testRoom.join(this)
return super.reconnect()
}
send (uid, message) {
this.testRoom.send(this.userId, uid, message)
}
broadcast (message) {
this.testRoom.broadcast(this.userId, message)
}
async whenSynced (f) {
var synced = false
var periodicFlushTillSync = () => {
if (synced) {
f()
} else {
this.testRoom.flushAll([this.y]).then(function () {
setTimeout(periodicFlushTillSync, 10)
})
}
}
periodicFlushTillSync()
return super.whenSynced(function () {
synced = true
})
}
receiveMessage (sender, m) {
if (this.userId !== sender && this.connections[sender] != null) {
var buffer = this.connections[sender].buffer
if (buffer == null) {
buffer = this.connections[sender].buffer = []
}
buffer.push(m)
if (this.chance.bool({likelihood: 30})) {
// flush 1/2 with 30% chance
var flushLength = Math.round(buffer.length / 2)
buffer.splice(0, flushLength).forEach(m => {
super.receiveMessage(sender, m)
})
}
}
}
async _flushAll (flushUsers) {
if (flushUsers.some(u => u.connector.userId === this.userId)) {
// this one needs to sync with every other user
flushUsers = Object.keys(this.connections).map(id => this.testRoom.users[id].y)
}
var finished = []
for (let i = 0; i < flushUsers.length; i++) {
let userId = flushUsers[i].connector.userId
if (userId === this.userId) continue
let buffer = this.connections[userId].buffer
if (buffer != null) {
var messages = buffer.splice(0)
for (let j = 0; j < messages.length; j++) {
let p = super.receiveMessage(userId, messages[j])
finished.push(p)
}
}
}
await Promise.all(finished)
await this.y.db.whenTransactionsFinished()
return finished.length > 0 ? 'flushing' : 'done'
}
}
Y.extend('test', TestConnector)
}
if (typeof Y !== 'undefined') {
extendTestConnector(Y)
}

28
tests/encoding.tests.js Normal file
View File

@@ -0,0 +1,28 @@
import * as t from 'lib0/testing.js'
import {
contentRefs,
readContentBinary,
readContentDeleted,
readContentString,
readContentJSON,
readContentEmbed,
readContentType,
readContentFormat,
readContentAny
} from '../src/internals.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 9)
t.assert(contentRefs[1] === readContentDeleted)
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)
}

23
tests/index.js Normal file
View File

@@ -0,0 +1,23 @@
import * as array from './y-array.tests.js'
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 { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js'
import * as log from 'lib0/logging.js'
if (isBrowser) {
log.createVConsole(document.body)
}
runTests({
map, array, text, xml, encoding, undoredo
}).then(success => {
/* istanbul ignore next */
if (isNode) {
process.exit(success ? 0 : 1)
}
})

416
tests/testHelper.js Normal file
View File

@@ -0,0 +1,416 @@
import * as Y from '../src/index.js'
import {
createDeleteSetFromStructStore,
getStateVector,
Item,
DeleteItem, DeleteSet, StructStore, Doc // eslint-disable-line
} from '../src/internals.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as syncProtocol from 'y-protocols/sync.js'
import * as object from 'lib0/object.js'
export * from '../src/internals.js'
/**
* @param {TestYInstance} y // publish message created by `y` to all other online clients
* @param {Uint8Array} m
*/
const broadcastMessage = (y, m) => {
if (y.tc.onlineConns.has(y)) {
y.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== y) {
remoteYInstance._receive(m, y)
}
})
}
}
export class TestYInstance extends Doc {
/**
* @param {TestConnector} testConnector
* @param {number} clientID
*/
constructor (testConnector, clientID) {
super()
this.userID = clientID // overwriting clientID
/**
* @type {TestConnector}
*/
this.tc = testConnector
/**
* @type {Map<TestYInstance, Array<Uint8Array>>}
*/
this.receiving = new Map()
testConnector.allConns.add(this)
// set up observe on local model
this.on('update', /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
if (origin !== testConnector) {
const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, update)
broadcastMessage(this, encoding.toUint8Array(encoder))
}
})
this.connect()
}
/**
* Disconnect from TestConnector.
*/
disconnect () {
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.
*/
connect () {
if (!this.tc.onlineConns.has(this)) {
this.tc.onlineConns.add(this)
const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, this)
// publish SyncStep1
broadcastMessage(this, encoding.toUint8Array(encoder))
this.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== this) {
// remote instance sends instance to this instance
const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, remoteYInstance)
this._receive(encoding.toUint8Array(encoder), remoteYInstance)
}
})
}
}
/**
* Receive a message from another client. This message is only appended to the list of receiving messages.
* TestConnector decides when this client actually reads this message.
*
* @param {Uint8Array} message
* @param {TestYInstance} remoteClient
*/
_receive (message, remoteClient) {
let messages = this.receiving.get(remoteClient)
if (messages === undefined) {
messages = []
this.receiving.set(remoteClient, messages)
}
messages.push(message)
}
}
/**
* Keeps track of TestYInstances.
*
* The TestYInstances add/remove themselves from the list of connections maiained in this object.
* I think it makes sense. Deal with it.
*/
export class TestConnector {
/**
* @param {prng.PRNG} gen
*/
constructor (gen) {
/**
* @type {Set<TestYInstance>}
*/
this.allConns = new Set()
/**
* @type {Set<TestYInstance>}
*/
this.onlineConns = new Set()
/**
* @type {prng.PRNG}
*/
this.prng = gen
}
/**
* Create a new Y instance and add it to the list of connections
* @param {number} clientID
*/
createY (clientID) {
return new TestYInstance(this, clientID)
}
/**
* Choose random connection and flush a random message from a random sender.
*
* If this function was unable to flush a message, because there are no more messages to flush, it returns false. true otherwise.
* @return {boolean}
*/
flushRandomMessage () {
const gen = this.prng
const conns = Array.from(this.onlineConns).filter(conn => conn.receiving.size > 0)
if (conns.length > 0) {
const receiver = prng.oneOf(gen, conns)
const [sender, messages] = prng.oneOf(gen, Array.from(receiver.receiving))
const m = messages.shift()
if (messages.length === 0) {
receiver.receiving.delete(sender)
}
if (m === undefined) {
return this.flushRandomMessage()
}
const encoder = encoding.createEncoder()
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
// do not publish data created when this function is executed (could be ss2 or update message)
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver, receiver.tc)
if (encoding.length(encoder) > 0) {
// send reply message
sender._receive(encoding.toUint8Array(encoder), receiver)
}
return true
}
return false
}
/**
* @return {boolean} True iff this function actually flushed something
*/
flushAllMessages () {
let didSomething = false
while (this.flushRandomMessage()) {
didSomething = true
}
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.
*/
disconnectRandom () {
if (this.onlineConns.size === 0) {
return false
}
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
return true
}
/**
* @return {boolean} Whether it was possible to reconnect a random connection.
*/
reconnectRandom () {
/**
* @type {Array<TestYInstance>}
*/
const reconnectable = []
this.allConns.forEach(conn => {
if (!this.onlineConns.has(conn)) {
reconnectable.push(conn)
}
})
if (reconnectable.length === 0) {
return false
}
prng.oneOf(this.prng, reconnectable).connect()
return true
}
}
/**
* @template T
* @param {t.TestCase} tc
* @param {{users?:number}} conf
* @param {InitTestObjectCallback<T>} [initTestObject]
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
*/
export const init = (tc, { users = 5 } = {}, initTestObject) => {
/**
* @type {Object<string,any>}
*/
const result = {
users: []
}
const gen = tc.prng
const testConnector = new TestConnector(gen)
result.testConnector = testConnector
for (let i = 0; i < users; i++) {
const y = testConnector.createY(i)
y.clientID = i
result.users.push(y)
result['array' + i] = y.get('array', Y.Array)
result['map' + i] = y.get('map', Y.Map)
result['xml' + i] = y.get('xml', Y.XmlElement)
result['text' + i] = y.get('text', Y.Text)
}
testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
return /** @type {any} */ (result)
}
/**
* 1. reconnect and flush all
* 2. user 0 gc
* 3. get type content
* 4. disconnect & reconnect all (so gc is propagated)
* 5. compare os, ds, ss
*
* @param {Array<TestYInstance>} users
*/
export const compare = users => {
users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {}
const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta())
for (const u of users) {
t.assert(u.store.pendingDeleteReaders.length === 0)
t.assert(u.store.pendingStack.length === 0)
t.assert(u.store.pendingClientsStructRefs.size === 0)
}
// Test Array iterator
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))
// Test Map iterator
const ymapkeys = Array.from(users[0].getMap('map').keys())
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
/**
* @type {Object<string,any>}
*/
const mapRes = {}
for (const [k, v] of users[0].getMap('map')) {
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
}
t.compare(userMapValues[0], mapRes)
// Compare all users
for (let i = 0; i < users.length - 1; i++) {
t.compare(userArrayValues[i].length, users[i].getArray('array').length)
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 => 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))
compareStructStores(users[i].store, users[i + 1].store)
}
users.map(u => u.destroy())
}
/**
* @param {Item?} a
* @param {Item?} b
* @return {boolean}
*/
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
/**
* @param {StructStore} ss1
* @param {StructStore} ss2
*/
export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size)
for (const [client, structs1] of ss1.clients) {
const structs2 = /** @type {Array<Y.AbstractStruct>} */ (ss2.clients.get(client))
t.assert(structs2 !== undefined && structs1.length === structs2.length)
for (let i = 0; i < structs1.length; i++) {
const s1 = structs1[i]
const s2 = structs2[i]
// checks for abstract struct
if (
s1.constructor !== s2.constructor ||
!Y.compareIDs(s1.id, s2.id) ||
s1.deleted !== s2.deleted ||
s1.length !== s2.length
) {
t.fail('Structs dont match')
}
if (s1 instanceof Item) {
if (
!(s2 instanceof Item) ||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
!compareItemIDs(s1.right, s2.right) ||
!Y.compareIDs(s1.origin, s2.origin) ||
!Y.compareIDs(s1.rightOrigin, s2.rightOrigin) ||
s1.parentSub !== s2.parentSub
) {
return t.fail('Items dont match')
}
// make sure that items are connected correctly
t.assert(s1.left === null || s1.left.right === s1)
t.assert(s1.right === null || s1.right.left === s1)
t.assert(s2.left === null || s2.left.right === s2)
t.assert(s2.right === null || s2.right.left === s2)
}
}
}
}
/**
* @param {DeleteSet} ds1
* @param {DeleteSet} ds2
*/
export const compareDS = (ds1, ds2) => {
t.assert(ds1.clients.size === ds2.clients.size)
for (const [client, deleteItems1] of ds1.clients) {
const deleteItems2 = /** @type {Array<DeleteItem>} */ (ds2.clients.get(client))
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i]
const di2 = deleteItems2[i]
if (di1.clock !== di2.clock || di1.len !== di2.len) {
t.fail('DeleteSets dont match')
}
}
}
}
/**
* @template T
* @callback InitTestObjectCallback
* @param {TestYInstance} y
* @return {T}
*/
/**
* @template T
* @param {t.TestCase} tc
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
* @param {number} iterations
* @param {InitTestObjectCallback<T>} [initTestObject]
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng
const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result
for (let i = 0; i < iterations; i++) {
if (prng.int31(gen, 0, 100) <= 2) {
// 2% chance to disconnect/reconnect a random user
if (prng.bool(gen)) {
testConnector.disconnectRandom()
} else {
testConnector.reconnectRandom()
}
} else if (prng.int31(gen, 0, 100) <= 1) {
// 1% chance to flush all
testConnector.flushAllMessages()
} else if (prng.int31(gen, 0, 100) <= 50) {
// 50% chance to flush a random message
testConnector.flushRandomMessage()
}
const user = prng.int31(gen, 0, users.length - 1)
const test = prng.oneOf(gen, mods)
test(users[user], gen, result.testObjects[user])
}
compare(users)
return result
}

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

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

494
tests/y-array.tests.js Normal file
View File

@@ -0,0 +1,494 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
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
*/
export const testDeleteInsert = tc => {
const { users, array0 } = init(tc, { users: 2 })
array0.delete(0, 0)
t.describe('Does not throw when deleting zero elements with position 0')
t.fails(() => {
array0.delete(1, 1)
})
array0.insert(0, ['A'])
array0.delete(1, 0)
t.describe('Does not throw when deleting zero elements with valid position 1')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertThreeElementsTryRegetProperty = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, true, false])
t.compare(array0.toJSON(), [1, true, false], '.toJSON() works')
testConnector.flushAllMessages()
t.compare(array1.toJSON(), [1, true, false], '.toJSON() works after sync')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testConcurrentInsertWithThreeConflicts = tc => {
var { users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, [0])
array1.insert(0, [1])
array2.insert(0, [2])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testConcurrentInsertDeleteWithThreeConflicts = tc => {
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
array0.insert(1, [0])
array1.delete(0)
array1.delete(1, 1)
array2.insert(1, [2])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertionsInLateSync = tc => {
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
users[2].disconnect()
array0.insert(1, ['user0'])
array1.insert(1, ['user1'])
array2.insert(1, ['user2'])
users[1].connect()
users[2].connect()
testConnector.flushAllMessages()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testDisconnectReallyPreventsSendingMessages = tc => {
var { testConnector, users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
users[2].disconnect()
array0.insert(1, ['user0'])
array1.insert(1, ['user1'])
t.compare(array0.toJSON(), ['x', 'user0', 'y'])
t.compare(array1.toJSON(), ['x', 'user1', 'y'])
users[1].connect()
users[2].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testDeletionsInLateSync = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
array1.delete(1, 1)
array0.delete(0, 2)
users[1].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertThenMergeDeleteOnSync = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
users[0].disconnect()
array1.delete(0, 3)
users[0].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Object<string,any>?}
*/
let event = null
array0.observe(e => {
event = e
})
array0.insert(0, [0, 1, 2])
t.assert(event !== null)
event = null
array0.delete(0)
t.assert(event !== null)
event = null
array0.delete(0, 2)
t.assert(event !== null)
event = null
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testNestedObserverEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<number>}
*/
const vals = []
array0.observe(e => {
if (array0.length === 1) {
// inserting, will call this observer again
// we expect that this observer is called after this event handler finishedn
array0.insert(1, [1])
vals.push(0)
} else {
// this should be called the second time an element is inserted (above case)
vals.push(1)
}
})
array0.insert(0, [0])
t.compareArrays(vals, [0, 1])
t.compareArrays(array0.toArray(), [0, 1])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteEventsForTypes = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Object<string,any>|null}
*/
let event = null
array0.observe(e => {
event = e
})
array0.insert(0, [new Y.Array()])
t.assert(event !== null)
event = null
array0.delete(0)
t.assert(event !== null)
event = null
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
*/
export const testInsertAndDeleteEventsForTypes2 = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<Object<string,any>>}
*/
const events = []
array0.observe(e => {
events.push(e)
})
array0.insert(0, ['hi', new Y.Map()])
t.assert(events.length === 1, 'Event is triggered exactly once for insertion of two elements')
array0.delete(1)
t.assert(events.length === 2, 'Event is triggered exactly once for deletion')
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
*/
export const testGarbageCollector = tc => {
const { testConnector, users, array0 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
users[0].disconnect()
array0.delete(0, 3)
users[0].connect()
testConnector.flushAllMessages()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEventTargetIsSetCorrectlyOnLocal = tc => {
const { array0, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event
array0.observe(e => {
event = e
})
array0.insert(0, ['stuff'])
t.assert(event.target === array0, '"target" property is set correctly')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEventTargetIsSetCorrectlyOnRemote = tc => {
const { testConnector, array0, array1, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event
array0.observe(e => {
event = e
})
array1.insert(0, ['stuff'])
testConnector.flushAllMessages()
t.assert(event.target === array0, '"target" property is set correctly')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testIteratingArrayContainingTypes = tc => {
const y = new Y.Doc()
const arr = y.getArray('arr')
const numItems = 10
for (let i = 0; i < numItems; i++) {
const map = new Y.Map()
map.set('value', i)
arr.push([map])
}
let cnt = 0
for (const item of arr) {
t.assert(item.get('value') === cnt++, 'value is correct')
}
y.destroy()
}
let _uniqueNumber = 0
const getUniqueNumber = () => _uniqueNumber++
/**
* @type {Array<function(Doc,prng.PRNG,any):void>}
*/
const arrayTransactions = [
function insert (user, gen) {
const yarray = user.getArray('array')
var uniqueNumber = getUniqueNumber()
var content = []
var len = prng.int31(gen, 1, 4)
for (var i = 0; i < len; i++) {
content.push(uniqueNumber)
}
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, content)
},
function insertTypeArray (user, gen) {
const yarray = user.getArray('array')
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Array()])
var array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (user, gen) {
const yarray = user.getArray('array')
var pos = prng.int31(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Map()])
var map = yarray.get(pos)
map.set('someprop', 42)
map.set('someprop', 43)
map.set('someprop', 44)
},
function _delete (user, gen) {
const yarray = user.getArray('array')
var length = yarray.length
if (length > 0) {
var somePos = prng.int31(gen, 0, length - 1)
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
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))
type.delete(somePos, delLength)
}
} else {
yarray.delete(somePos, delLength)
}
}
}
]
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests4 = tc => {
applyRandomTests(tc, arrayTransactions, 4)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests40 = tc => {
applyRandomTests(tc, arrayTransactions, 40)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests42 = tc => {
applyRandomTests(tc, arrayTransactions, 42)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests43 = tc => {
applyRandomTests(tc, arrayTransactions, 43)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests44 = tc => {
applyRandomTests(tc, arrayTransactions, 44)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests45 = tc => {
applyRandomTests(tc, arrayTransactions, 45)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests46 = tc => {
applyRandomTests(tc, arrayTransactions, 46)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests300 = tc => {
applyRandomTests(tc, arrayTransactions, 300)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests400 = tc => {
applyRandomTests(tc, arrayTransactions, 400)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests500 = tc => {
applyRandomTests(tc, arrayTransactions, 500)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests600 = tc => {
applyRandomTests(tc, arrayTransactions, 600)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1000 = tc => {
applyRandomTests(tc, arrayTransactions, 1000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1800 = tc => {
applyRandomTests(tc, arrayTransactions, 1800)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests3000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 3000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests5000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 5000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests30000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 30000)
}

566
tests/y-map.tests.js Normal file
View File

@@ -0,0 +1,566 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import {
compareIDs
} from '../src/internals.js'
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 testBasicMapTests = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
users[2].disconnect()
map0.set('number', 1)
map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } })
map0.set('y-map', new Y.Map())
map0.set('boolean1', true)
map0.set('boolean0', false)
const map = map0.get('y-map')
map.set('y-array', new Y.Array())
const array = map.get('y-array')
array.insert(0, [0])
array.insert(0, [-1])
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
users[2].connect()
testConnector.flushAllMessages()
t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
// compare disconnected user
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')
t.assert(map2.get('boolean1') === true, 'client 2 computed the change (boolean)')
t.compare(map2.get('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
t.assert(map2.get('y-map').get('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapProperty = tc => {
const { testConnector, users, map0 } = init(tc, { users: 2 })
map0.set('stuff', 'stuffy')
map0.set('undefined', undefined)
map0.set('null', null)
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
t.assert(u.get('undefined') === undefined, 'undefined')
t.compare(u.get('null'), null, 'null')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYmap = tc => {
const { users, map0 } = init(tc, { users: 2 })
const map = map0.set('Map', new Y.Map())
t.assert(map0.get('Map') === map)
map.set('one', 1)
t.compare(map.get('one'), 1)
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYarray = tc => {
const { users, map0 } = init(tc, { users: 2 })
const array = map0.set('Array', new Y.Array())
t.assert(array === map0.get('Array'))
array.insert(0, [1, 2, 3])
// @ts-ignore
t.compare(map0.toJSON(), { Array: [1, 2, 3] })
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertySyncs = tc => {
const { testConnector, users, map0 } = init(tc, { users: 2 })
map0.set('stuff', 'stuffy')
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithConflict = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c1')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapProperty = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
t.compare(u.get('stuff'), 'c3')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
map0.set('stuff', 'deleteme')
map1.set('stuff', 'c1')
map2.set('stuff', 'c2')
map3.set('stuff', 'c3')
map3.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
var u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testObserveDeepProperties = tc => {
const { testConnector, users, map1, map2, map3 } = init(tc, { users: 4 })
const _map1 = map1.set('map', new Y.Map())
let calls = 0
let dmapid
map1.observeDeep(events => {
events.forEach(event => {
calls++
// @ts-ignore
t.assert(event.keysChanged.has('deepmap'))
t.assert(event.path.length === 1)
t.assert(event.path[0] === 'map')
// @ts-ignore
dmapid = event.target.get('deepmap')._item.id
})
})
testConnector.flushAllMessages()
const _map3 = map3.get('map')
_map3.set('deepmap', new Y.Map())
testConnector.flushAllMessages()
const _map2 = map2.get('map')
_map2.set('deepmap', new Y.Map())
testConnector.flushAllMessages()
const dmap1 = _map1.get('deepmap')
const dmap2 = _map2.get('deepmap')
const dmap3 = _map3.get('deepmap')
t.assert(calls > 0)
t.assert(compareIDs(dmap1._item.id, dmap2._item.id))
t.assert(compareIDs(dmap1._item.id, dmap3._item.id))
// @ts-ignore we want the possibility of dmapid being undefined
t.assert(compareIDs(dmap1._item.id, dmapid))
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testObserversUsingObservedeep = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Array<Array<string|number>>}
*/
const pathes = []
let calls = 0
map0.observeDeep(events => {
events.forEach(event => {
pathes.push(event.path)
})
calls++
})
map0.set('map', new Y.Map())
map0.get('map').set('array', new Y.Array())
map0.get('map').get('array').insert(0, ['content'])
t.assert(calls === 3)
t.compare(pathes, [[], ['map'], ['map', 'array']])
compare(users)
}
// TODO: Test events in Y.Map
/**
* @param {Object<string,any>} is
* @param {Object<string,any>} should
*/
const compareEvent = (is, should) => {
for (var key in should) {
t.compare(should[key], is[key])
}
}
/**
* @param {t.TestCase} tc
*/
export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e // just put it on event, should be thrown synchronously anyway
})
map0.set('stuff', 4)
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in contents
map0.set('stuff', new Y.Array())
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in opContents
map0.set('stuff', 5)
// delete
map0.delete('stuff')
compareEvent(event, {
keysChanged: new Set(['stuff']),
target: map0
})
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
*/
export const testYmapEventHasCorrectValueWhenSettingAPrimitive = tc => {
const { users, map0 } = init(tc, { users: 3 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e
})
map0.set('stuff', 2)
t.compare(event.value, event.target.get(event.name))
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc => {
const { users, map0, map1, testConnector } = init(tc, { users: 3 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e
})
map1.set('stuff', 2)
testConnector.flushAllMessages()
t.compare(event.value, event.target.get(event.name))
compare(users)
}
/**
* @type {Array<function(Doc,prng.PRNG):void>}
*/
const mapTransactions = [
function set (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
var value = prng.utf16String(gen)
user.getMap('map').set(key, value)
},
function setType (user, gen) {
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) {
type.insert(0, [1, 2, 3, 4])
} else {
type.set('deepkey', 'deepvalue')
}
},
function _delete (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
user.getMap('map').delete(key)
}
]
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests10 = tc => {
applyRandomTests(tc, mapTransactions, 10)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests40 = tc => {
applyRandomTests(tc, mapTransactions, 40)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests42 = tc => {
applyRandomTests(tc, mapTransactions, 42)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests43 = tc => {
applyRandomTests(tc, mapTransactions, 43)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests44 = tc => {
applyRandomTests(tc, mapTransactions, 44)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests45 = tc => {
applyRandomTests(tc, mapTransactions, 45)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests46 = tc => {
applyRandomTests(tc, mapTransactions, 46)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests300 = tc => {
applyRandomTests(tc, mapTransactions, 300)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests400 = tc => {
applyRandomTests(tc, mapTransactions, 400)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests500 = tc => {
applyRandomTests(tc, mapTransactions, 500)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests600 = tc => {
applyRandomTests(tc, mapTransactions, 600)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests1000 = tc => {
applyRandomTests(tc, mapTransactions, 1000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests1800 = tc => {
applyRandomTests(tc, mapTransactions, 1800)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests5000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 5000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests10000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 10000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests100000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 100000)
}

160
tests/y-text.tests.js Normal file
View File

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

75
tests/y-xml.tests.js Normal file
View File

@@ -0,0 +1,75 @@
import { init, compare } from './testHelper.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
/**
* @param {t.TestCase} tc
*/
export const testSetProperty = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
xml0.setAttribute('height', '10')
t.assert(xml0.getAttribute('height') === '10', 'Simple set+get works')
testConnector.flushAllMessages()
t.assert(xml1.getAttribute('height') === '10', 'Simple set+get works (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEvents = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
/**
* @type {any}
*/
let event
/**
* @type {any}
*/
let remoteEvent
xml0.observe(e => {
event = e
})
xml1.observe(e => {
remoteEvent = e
})
xml0.setAttribute('key', 'value')
t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key')
testConnector.flushAllMessages()
t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key (remote)')
// check attributeRemoved
xml0.removeAttribute('key')
t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute')
testConnector.flushAllMessages()
t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute (remote)')
xml0.insert(0, [new Y.XmlText('some text')])
t.assert(event.childListChanged, 'YXmlEvent.childListChanged on inserted element')
testConnector.flushAllMessages()
t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on inserted element (remote)')
// test childRemoved
xml0.delete(0)
t.assert(event.childListChanged, 'YXmlEvent.childListChanged on deleted element')
testConnector.flushAllMessages()
t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on deleted element (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testTreewalker = tc => {
const { users, xml0 } = init(tc, { users: 3 })
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')])
const allParagraphs = xml0.querySelectorAll('p')
t.assert(allParagraphs.length === 2, 'found exactly two paragraphs')
t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1')
t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2')
t.assert(xml0.querySelector('p') === paragraph1, 'querySelector found paragraph1')
compare(users)
}

68
tsconfig.json Normal file
View File

@@ -0,0 +1,68 @@
{
"compilerOptions": {
/* Basic Options */
"target": "es2018",
"lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */
"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. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "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. */
// "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'). */
/* 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. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": {
"yjs": ["./src/index.js"],
"lib0/*": ["node_modules/lib0/*"],
"lib0/set.js": ["node_modules/lib0/set.js"],
"lib0/function.js": ["node_modules/lib0/function.js"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"maxNodeModuleJsDepth": 5,
// "types": ["./src/utils/typedefs.js"]
},
"include": ["./src/**/*.js", "./tests/**/*.js"],
"exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
}