Compare commits
661 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3b0f3043 | ||
|
|
94646b2f45 | ||
|
|
29c2ad4492 | ||
|
|
637fadf38e | ||
|
|
0c6c11d583 | ||
|
|
6f9a2c9df7 | ||
|
|
7876a96163 | ||
|
|
22653c799c | ||
|
|
68109b033f | ||
|
|
38eb2e502c | ||
|
|
270a69fcf6 | ||
|
|
6e3b708599 | ||
|
|
6e8167fe51 | ||
|
|
3449687280 | ||
|
|
3406247a3e | ||
|
|
076d550dfa | ||
|
|
bb45816f05 | ||
|
|
5414ac7f6e | ||
|
|
0b8f032364 | ||
|
|
dc136ff56a | ||
|
|
b73a720fdc | ||
|
|
cf420d6241 | ||
|
|
859e169c91 | ||
|
|
6c2cf0f769 | ||
|
|
1a942aa4e0 | ||
|
|
368dc6b36a | ||
|
|
2151c514e5 | ||
|
|
bb25ce7731 | ||
|
|
e31e968f0d | ||
|
|
1a494761a3 | ||
|
|
b434501d11 | ||
|
|
d1d86277b8 | ||
|
|
d7a11ccf4d | ||
|
|
4c48116947 | ||
|
|
6dd26d3b48 | ||
|
|
6b0154f046 | ||
|
|
7fb63de8fc | ||
|
|
c4d80d133d | ||
|
|
cebe96c001 | ||
|
|
4d2369ce21 | ||
|
|
5293ab4df1 | ||
|
|
e53c01c6c5 | ||
|
|
03faa27787 | ||
|
|
868dd5f0a5 | ||
|
|
fa58ce53cd | ||
|
|
0a0098fdfb | ||
|
|
a5a48d07f6 | ||
|
|
7b16d5c92d | ||
|
|
ee147c14f1 | ||
|
|
e86d5ba25b | ||
|
|
149ca6f636 | ||
|
|
e4223760b0 | ||
|
|
9d3dd4e082 | ||
|
|
5a4ff33bf4 | ||
|
|
a059fa12e9 | ||
|
|
0628d8f1c9 | ||
|
|
19e2d51190 | ||
|
|
60fab42b3f | ||
|
|
469404c6e1 | ||
|
|
c9756e5b57 | ||
|
|
601d24e930 | ||
|
|
b2c16674f2 | ||
|
|
13da804b5e | ||
|
|
c5ca7b6f8c | ||
|
|
f4b68c0dd4 | ||
|
|
4407f70052 | ||
|
|
8bb52a485a | ||
|
|
9fc18d5ce0 | ||
|
|
ada4f400b5 | ||
|
|
06048b87ee | ||
|
|
05dde1db01 | ||
|
|
b5b32c5b3c | ||
|
|
3f0e2078de | ||
|
|
21470bb409 | ||
|
|
772bb87d5c | ||
|
|
dab172fa1d | ||
|
|
a70c5112cd | ||
|
|
7cb423c046 | ||
|
|
4547b35641 | ||
|
|
4c87f9a021 | ||
|
|
4b08c67e06 | ||
|
|
9f5bc9ddfe | ||
|
|
8221db795a | ||
|
|
68b4418956 | ||
|
|
fa09ebfd82 | ||
|
|
b399ffa765 | ||
|
|
180f4667c1 | ||
|
|
9455373611 | ||
|
|
aa804d89c0 | ||
|
|
3ef51a5d1a | ||
|
|
e61089c659 | ||
|
|
97625cf29b | ||
|
|
a5dc6c27aa | ||
|
|
26a51bafc9 | ||
|
|
f40e09d156 | ||
|
|
81650bc8f6 | ||
|
|
c87caafeb6 | ||
|
|
195b26d90f | ||
|
|
7e0189ca84 | ||
|
|
192706f2a8 | ||
|
|
a4ce8ae07d | ||
|
|
e04a980af1 | ||
|
|
47d40eb6b0 | ||
|
|
fc4a39cc7d | ||
|
|
44e1fd9f14 | ||
|
|
02cc5a215f | ||
|
|
d1e8d50c43 | ||
|
|
18bb2d0719 | ||
|
|
45df311dd7 | ||
|
|
62888b4004 | ||
|
|
76c389dba0 | ||
|
|
78fa98c000 | ||
|
|
e9f9e08450 | ||
|
|
e3c59b0aa7 | ||
|
|
705dce7838 | ||
|
|
0fb55981ba | ||
|
|
89378e29ae | ||
|
|
cce35270ec | ||
|
|
d78180bf97 | ||
|
|
0ab415de3e | ||
|
|
ff3969caeb | ||
|
|
c82cc9f8d6 | ||
|
|
ef5c71bd8b | ||
|
|
bd6be3d23b | ||
|
|
0e6deab9c9 | ||
|
|
6cd9e2be32 | ||
|
|
ac8dab1e88 | ||
|
|
38ed725c2c | ||
|
|
a210bad25e | ||
|
|
6929a4f0f8 | ||
|
|
52dacfa5f2 | ||
|
|
27efe86f9c | ||
|
|
882b9055c7 | ||
|
|
e089089413 | ||
|
|
197932752e | ||
|
|
f0b2bdaf34 | ||
|
|
b96362c0f1 | ||
|
|
67f241cd7a | ||
|
|
c8af0bebf7 | ||
|
|
4f35e799a6 | ||
|
|
eb2a52dd26 | ||
|
|
189b1068ae | ||
|
|
7a3b60a5d7 | ||
|
|
99f06fc093 | ||
|
|
22917bca19 | ||
|
|
7f0e25dcba | ||
|
|
d90c9b1cb2 | ||
|
|
c426055f17 | ||
|
|
18c9010b63 | ||
|
|
c3edac62ef | ||
|
|
755de18fd5 | ||
|
|
641dc25076 | ||
|
|
1d58ea785f | ||
|
|
f53dff5043 | ||
|
|
74d1a31f49 | ||
|
|
d1063ab70b | ||
|
|
f4c919d9ec | ||
|
|
aeb23dbaa9 | ||
|
|
6d4f0c0cdd | ||
|
|
303138f309 | ||
|
|
ad373a3dce | ||
|
|
2150fa58f2 | ||
|
|
ece4841b5c | ||
|
|
8103220c05 | ||
|
|
66d500f08d | ||
|
|
5f8e7c7ba7 | ||
|
|
7b8eee6b25 | ||
|
|
1d5947c602 | ||
|
|
53e4028952 | ||
|
|
b38a8d99e5 | ||
|
|
6c4971ae25 | ||
|
|
d1f5ff0f59 | ||
|
|
1d297601e8 | ||
|
|
d9fface0be | ||
|
|
7d5db917da | ||
|
|
6e7529723d | ||
|
|
6cb64b3707 | ||
|
|
bb1c0b809f | ||
|
|
8bcff6138c | ||
|
|
e78d84ee59 | ||
|
|
c23bcb66ce | ||
|
|
5fddcef3ea | ||
|
|
e1e46c6eb1 | ||
|
|
13ad0c8464 | ||
|
|
7700b50470 | ||
|
|
fc4d6165b4 | ||
|
|
251c8aaefc | ||
|
|
1337d38ada | ||
|
|
f5c66e41cb | ||
|
|
0e7da017fe | ||
|
|
f0262ffaae | ||
|
|
36203af88e | ||
|
|
dd2b8bc6c7 | ||
|
|
463065ac21 | ||
|
|
d064e6e96e | ||
|
|
b1ed2df208 | ||
|
|
1fe4ef135c | ||
|
|
e376b5d472 | ||
|
|
952a9b2c41 | ||
|
|
03458dc641 | ||
|
|
14df5b72af | ||
|
|
338968031b | ||
|
|
1aac245b93 | ||
|
|
1faff323c1 | ||
|
|
e7280c7ae2 | ||
|
|
4c38619b5d | ||
|
|
b4e5c5cc1f | ||
|
|
b0dbd84f7f | ||
|
|
4a990963d9 | ||
|
|
7e7c9d5b11 | ||
|
|
775f6eed1d | ||
|
|
1e83b9418c | ||
|
|
ac3f672c80 | ||
|
|
2192aa5821 | ||
|
|
70bb523005 | ||
|
|
10ce6de57a | ||
|
|
3fba4f25a5 | ||
|
|
66c35d8499 | ||
|
|
4c14157dcf | ||
|
|
ef6c382e20 | ||
|
|
ee45b4fdd6 | ||
|
|
668e9e8a9b | ||
|
|
37a6d68543 | ||
|
|
f893198769 | ||
|
|
d3ee1a0ec2 | ||
|
|
d6593412a2 | ||
|
|
d31bf36531 | ||
|
|
a485f550db | ||
|
|
0610b16227 | ||
|
|
72e470c5f0 | ||
|
|
4d12a02e2f | ||
|
|
4a7d6f0a2d | ||
|
|
c80f446b5f | ||
|
|
81a529d8dc | ||
|
|
4f0ab78914 | ||
|
|
8c36f67f0b | ||
|
|
77687d94e6 | ||
|
|
4644511303 | ||
|
|
20005eecdb | ||
|
|
c9dda245bf | ||
|
|
1417470156 | ||
|
|
584e5dfd40 | ||
|
|
805acbb9f5 | ||
|
|
32c4c09072 | ||
|
|
8c5a06bbf8 | ||
|
|
a336cc167c | ||
|
|
21d86cd2be | ||
|
|
1d0f9faa91 | ||
|
|
45237571b7 | ||
|
|
bb6f6cd141 | ||
|
|
729c1f16b8 | ||
|
|
b6059704aa | ||
|
|
fa3c92f44c | ||
|
|
cd82de7742 | ||
|
|
07a6a0044b | ||
|
|
4582832a71 | ||
|
|
07ac1d03e3 | ||
|
|
cbcf1facb8 | ||
|
|
31ff7ac78c | ||
|
|
ed3b31e58f | ||
|
|
759ecb21f7 | ||
|
|
9c29d820c8 | ||
|
|
2ef11a5344 | ||
|
|
9fe47e98d5 | ||
|
|
654510f3ff | ||
|
|
52ec698635 | ||
|
|
1b06f59d1c | ||
|
|
12bcc4d080 | ||
|
|
e1a9f314a7 | ||
|
|
7a111de186 | ||
|
|
90b3fa9dd9 | ||
|
|
c635963747 | ||
|
|
1b17b5e400 | ||
|
|
61d9d96d15 | ||
|
|
7d0c048708 | ||
|
|
8a7416ad50 | ||
|
|
e56899a02c | ||
|
|
30bf3742c9 | ||
|
|
8dbd2c4696 | ||
|
|
6578727c9c | ||
|
|
92ca001cdc | ||
|
|
415de1cc4c | ||
|
|
e23582b1cd | ||
|
|
73c28952c2 | ||
|
|
1bc1e88d6a | ||
|
|
c188f813a4 | ||
|
|
ff981a8697 | ||
|
|
d9ab593b07 | ||
|
|
293527e62b | ||
|
|
5a42a94cf4 | ||
|
|
040808300c | ||
|
|
57975d409e | ||
|
|
306b2c64f3 | ||
|
|
585265e9a5 | ||
|
|
777ae9503a | ||
|
|
4c1798e5fa | ||
|
|
f4d85e2a3e | ||
|
|
a0f0c9c377 | ||
|
|
95ec2a435a | ||
|
|
da9836fe59 | ||
|
|
3a7411f9e8 | ||
|
|
39cee7c6e7 | ||
|
|
0a5753c191 | ||
|
|
76b7d0b651 | ||
|
|
99e3e95a00 | ||
|
|
93ee4ee287 | ||
|
|
c5cc403a29 | ||
|
|
75f4a0a5f0 | ||
|
|
591df5c00a | ||
|
|
f6b4819ae3 | ||
|
|
d483d9cc83 | ||
|
|
453407b93d | ||
|
|
e699f92333 | ||
|
|
6ff47719ef | ||
|
|
3a0694c35c | ||
|
|
74e5243742 | ||
|
|
dcf43b9797 | ||
|
|
77e479c03b | ||
|
|
ec58a99748 | ||
|
|
f1eb66655b | ||
|
|
7f4ae9fe14 | ||
|
|
c0ba56a21f | ||
|
|
4063e28b5e | ||
|
|
b6f7cd7869 | ||
|
|
1a79e429ed | ||
|
|
04066a5678 | ||
|
|
e09ef15349 | ||
|
|
3d70eee959 | ||
|
|
582095e5a3 | ||
|
|
c9ea3a412e | ||
|
|
a2c51c36e9 | ||
|
|
ab3dba5b06 | ||
|
|
3ddff186c2 | ||
|
|
9bd199a6e7 | ||
|
|
01d0825ae6 | ||
|
|
e2f98525d2 | ||
|
|
70a0a03130 | ||
|
|
656d85c62e | ||
|
|
e168dd48fb | ||
|
|
12d43199d5 | ||
|
|
539fa8b21d | ||
|
|
f572f94586 | ||
|
|
c12d00b227 | ||
|
|
e4a5f2caec | ||
|
|
9f9f465238 | ||
|
|
8450ff86d7 | ||
|
|
70139262c5 | ||
|
|
9c0da271eb | ||
|
|
ade3e1949d | ||
|
|
eec63a008f | ||
|
|
52abcdd043 | ||
|
|
f94653424a | ||
|
|
d67a794e2c | ||
|
|
60318083a6 | ||
|
|
7607070452 | ||
|
|
28fb7b6e9c | ||
|
|
aafe15757f | ||
|
|
31d6ef6296 | ||
|
|
32b8fac37f | ||
|
|
e8060de914 | ||
|
|
22b036527c | ||
|
|
feb1e030d7 | ||
|
|
bd271e3952 | ||
|
|
df80938190 | ||
|
|
67bbc0a3fe | ||
|
|
e1ece6dc66 | ||
|
|
fe038822a3 | ||
|
|
dece14486c | ||
|
|
2daffbc2ca | ||
|
|
4c01a34d09 | ||
|
|
3b08267daa | ||
|
|
b98ebddb69 | ||
|
|
9d5bf50676 | ||
|
|
c0972f8158 | ||
|
|
548125a944 | ||
|
|
a7b124ca6e | ||
|
|
4022374620 | ||
|
|
860e4d7af6 | ||
|
|
6376d69b58 | ||
|
|
5cf6f45f19 | ||
|
|
967903673b | ||
|
|
2d897f1844 | ||
|
|
fb2f9bc493 | ||
|
|
6f9ae0c4fc | ||
|
|
9df20fac8a | ||
|
|
a1fb1a6258 | ||
|
|
417d0ef3b5 | ||
|
|
9be256231b | ||
|
|
c122bdc750 | ||
|
|
4ef36ab81c | ||
|
|
cccc0e1015 | ||
|
|
db5312443e | ||
|
|
dbda07424b | ||
|
|
684d38d6c8 | ||
|
|
44fa064eb2 | ||
|
|
9b6fffd880 | ||
|
|
e9993b2643 | ||
|
|
762e9e8a3a | ||
|
|
6ddeb788c7 | ||
|
|
b9245f323c | ||
|
|
c0e630b635 | ||
|
|
e56457a0ef | ||
|
|
ca13849828 | ||
|
|
92c2fbd6d3 | ||
|
|
65b8921f05 | ||
|
|
1ace7f4b73 | ||
|
|
6336064516 | ||
|
|
49d2e42b41 | ||
|
|
c098e8e745 | ||
|
|
38558a7fad | ||
|
|
bdb3782f8f | ||
|
|
bc32f7348e | ||
|
|
09a94f053e | ||
|
|
0df0079fa3 | ||
|
|
a54d826d6d | ||
|
|
99f92cb9a0 | ||
|
|
e788ad1333 | ||
|
|
1fe37c565e | ||
|
|
ed2273e2ed | ||
|
|
94933a704d | ||
|
|
ef6eb08335 | ||
|
|
d915c8dd13 | ||
|
|
32207cbca0 | ||
|
|
135c6d31be | ||
|
|
61149b458a | ||
|
|
ba97bfdd9e | ||
|
|
689bca8602 | ||
|
|
6dd43cde17 | ||
|
|
026675b438 | ||
|
|
941a22b257 | ||
|
|
4aa41b98a9 | ||
|
|
acf443aacb | ||
|
|
aa8c934833 | ||
|
|
814af5a3d7 | ||
|
|
bbc207aaa6 | ||
|
|
a9b610479d | ||
|
|
079de07eff | ||
|
|
54453e87fa | ||
|
|
1b0e3659c3 | ||
|
|
dc22a79ac4 | ||
|
|
384a4b72b0 | ||
|
|
f35c056bde | ||
|
|
250050e83b | ||
|
|
248d08be30 | ||
|
|
641f426339 | ||
|
|
fcbca65d8f | ||
|
|
5f8ae0dd43 | ||
|
|
de14fe0f3e | ||
|
|
5e4b071693 | ||
|
|
937de2c59f | ||
|
|
f1f1bff901 | ||
|
|
da748a78f4 | ||
|
|
4855b2d590 | ||
|
|
908ce31e2f | ||
|
|
e4d4c23f0b | ||
|
|
fc500a8247 | ||
|
|
4b84541d76 | ||
|
|
a3ab42c157 | ||
|
|
bbd3317d62 | ||
|
|
5d3922cb64 | ||
|
|
a81a2cd553 | ||
|
|
c0d24bdba4 | ||
|
|
40e913e9c5 | ||
|
|
94f6a0fd9c | ||
|
|
41a88dbc43 | ||
|
|
1d4f283955 | ||
|
|
fc3a4c376c | ||
|
|
acb0affa33 | ||
|
|
0b510b64a3 | ||
|
|
c8f0cf5556 | ||
|
|
11a4271fd1 | ||
|
|
c7670915c7 | ||
|
|
eb2d596538 | ||
|
|
48e17ea1a7 | ||
|
|
1a22fdd45e | ||
|
|
07cf0b3436 | ||
|
|
5a68b9f4ad | ||
|
|
445dd3e0da | ||
|
|
0ba97d78f8 | ||
|
|
fc5be5c7cc | ||
|
|
f2debc150c | ||
|
|
08f37a86e3 | ||
|
|
f5d17e6236 | ||
|
|
8f3bd7170a | ||
|
|
5586334549 | ||
|
|
24c1e4dcc8 | ||
|
|
d61bbecf4e | ||
|
|
85492ad2e0 | ||
|
|
02253f9a8d | ||
|
|
8105bef1af | ||
|
|
4efa16e2dd | ||
|
|
ad44f59def | ||
|
|
9c471ea24d | ||
|
|
d9e76014f5 | ||
|
|
4091b7d004 | ||
|
|
dfc183643d | ||
|
|
cf8698f2b6 | ||
|
|
3595f14da7 | ||
|
|
c6e671b1d5 | ||
|
|
e4c10fd6b3 | ||
|
|
e70aa09f88 | ||
|
|
7808b143da | ||
|
|
b35092928e | ||
|
|
b7dbcf69d3 | ||
|
|
377df18788 | ||
|
|
26a323733d | ||
|
|
d0d1015074 | ||
|
|
2e3240b379 | ||
|
|
2558652356 | ||
|
|
783cbd63fc | ||
|
|
41be80e751 | ||
|
|
3d6050d8a2 | ||
|
|
3d5ba7b4cc | ||
|
|
415b66607c | ||
|
|
05cd1d0575 | ||
|
|
4edc22bedb | ||
|
|
16f84c67d5 | ||
|
|
290d3c8ffe | ||
|
|
c51e8b46c2 | ||
|
|
0cda1630d2 | ||
|
|
d232b883e9 | ||
|
|
3a0e65403f | ||
|
|
224fff93ba | ||
|
|
4f55e8c655 | ||
|
|
a08624c04e | ||
|
|
9b00929172 | ||
|
|
b94267e14a | ||
|
|
e696304845 | ||
|
|
d503c9d640 | ||
|
|
e5f289506f | ||
|
|
c453593ee7 | ||
|
|
5ed1818de5 | ||
|
|
0310500c4e | ||
|
|
b7defc32e8 | ||
|
|
dbdd49af23 | ||
|
|
b7c05ba133 | ||
|
|
9298903bdb | ||
|
|
d59e30b239 | ||
|
|
d29b83a457 | ||
|
|
0208d83f91 | ||
|
|
c545118637 | ||
|
|
c619aa33d9 | ||
|
|
1dea8f394f | ||
|
|
5cf8d20cf6 | ||
|
|
74f9ceab01 | ||
|
|
ca81cdf3be | ||
|
|
96c6aa2751 | ||
|
|
e6b5e258fb | ||
|
|
e8170a09a7 | ||
|
|
9d1ad8cb28 | ||
|
|
d859fd68fe | ||
|
|
2b7d2ed1e6 | ||
|
|
142a5ada60 | ||
|
|
c92f987496 | ||
|
|
755c9eb16e | ||
|
|
1311c7a0d8 | ||
|
|
4eec8ecdd3 | ||
|
|
0e426f8928 | ||
|
|
82015d5a37 | ||
|
|
d9ee67d2f3 | ||
|
|
791f6c12f0 | ||
|
|
23d019c244 | ||
|
|
c8ca80d15f | ||
|
|
be282c8338 | ||
|
|
829a094c6d | ||
|
|
725273167e | ||
|
|
581264c5e3 | ||
|
|
be537c9f8c | ||
|
|
4028eee39d | ||
|
|
0e3e561ec7 | ||
|
|
7df46cb731 | ||
|
|
40fb16ef32 | ||
|
|
ada5d36cd5 | ||
|
|
f537a43e29 | ||
|
|
3a305fb228 | ||
|
|
1afdab376d | ||
|
|
526c862071 | ||
|
|
fdbb558ce2 | ||
|
|
76ad58bb59 | ||
|
|
c88a813bb0 | ||
|
|
ccf6d86c98 | ||
|
|
6b5c02f1ce | ||
|
|
2be6e935a4 | ||
|
|
0ddf3bf742 | ||
|
|
5f29724578 | ||
|
|
ab6cde07e6 | ||
|
|
0455eaa8ad | ||
|
|
9ed7e15d0f | ||
|
|
6e633d0bd9 | ||
|
|
e16195cb54 | ||
|
|
86c46cf0ec | ||
|
|
8770c8e934 | ||
|
|
7e12ea2db5 | ||
|
|
3ca260e0da | ||
|
|
edb5e4f719 | ||
|
|
be3b8b65ce | ||
|
|
d093ef56c8 | ||
|
|
90b2a895b8 | ||
|
|
4f57c91b82 | ||
|
|
3e1d89253f | ||
|
|
03e1a3fc12 | ||
|
|
5c33f41c30 | ||
|
|
65e8c29b33 | ||
|
|
fed77d532f | ||
|
|
d129184f7b | ||
|
|
a05bb1d4f9 | ||
|
|
65af4963e6 | ||
|
|
4dce0816a6 | ||
|
|
5384bf4faf | ||
|
|
454ac9ba16 | ||
|
|
e2ec53be65 | ||
|
|
aa6edcfd9b | ||
|
|
f31ec9a8b8 | ||
|
|
003fa735a0 | ||
|
|
574f0c3269 | ||
|
|
eb4fb3a225 | ||
|
|
c97130abc4 | ||
|
|
a19cfa1465 | ||
|
|
bb45abbb70 | ||
|
|
67b47fd868 | ||
|
|
2c18b9ffad | ||
|
|
a6b7d76544 | ||
|
|
442ea7ec70 | ||
|
|
747da52c0b | ||
|
|
6c37bd4463 | ||
|
|
252bec0ad2 | ||
|
|
6c8876d282 | ||
|
|
3c317828d1 | ||
|
|
cd3f4a72d6 | ||
|
|
2c852c85c6 | ||
|
|
434ec84837 | ||
|
|
2b618cd83c | ||
|
|
f4327529b9 | ||
|
|
67189f4d44 | ||
|
|
6225fb4dfd | ||
|
|
a7550fe5d3 | ||
|
|
9d9c84f40e | ||
|
|
ae91902de3 | ||
|
|
033d24eee7 | ||
|
|
8abef69aa7 | ||
|
|
7e4dedab38 | ||
|
|
85e488bbe6 | ||
|
|
a6a321da10 | ||
|
|
008764ccdc | ||
|
|
de5f4abe32 | ||
|
|
382d06f6d4 | ||
|
|
66de422749 | ||
|
|
bbf5e39408 | ||
|
|
c8bca15d72 | ||
|
|
a64730e651 | ||
|
|
409a9414f1 | ||
|
|
24facaab09 | ||
|
|
060549f2cb | ||
|
|
dfe3b0b1d1 | ||
|
|
e23154bec2 | ||
|
|
1682d43c26 | ||
|
|
68c417fe6f | ||
|
|
2ea163a5cf | ||
|
|
020dacdad4 | ||
|
|
42abcc897c | ||
|
|
0a321610aa |
12
.flowconfig
12
.flowconfig
@@ -1,12 +0,0 @@
|
||||
[ignore]
|
||||
.*/node_modules/.*
|
||||
.*/dist/.*
|
||||
.*/build/.*
|
||||
|
||||
[include]
|
||||
./src/
|
||||
|
||||
[libs]
|
||||
./declarations/
|
||||
|
||||
[options]
|
||||
31
.github/workflows/nodejs.yml
vendored
Normal file
31
.github/workflows/nodejs.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 13.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run test-extensive
|
||||
env:
|
||||
CI: true
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "dist"]
|
||||
path = dist
|
||||
url = https://github.com/y-js/yjs.git
|
||||
branch = dist
|
||||
52
.jsdoc.json
Normal file
52
.jsdoc.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"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": "Website",
|
||||
"docs.yjs.dev": "Docs",
|
||||
"discuss.yjs.dev": "Forum",
|
||||
"https://gitter.im/Yjs/community": "Chat"
|
||||
},
|
||||
"logo": {
|
||||
"url": "https://yjs.dev/images/logo/yjs-512x512.png",
|
||||
"width": "162px",
|
||||
"height": "162px",
|
||||
"link": "/"
|
||||
},
|
||||
"tabNames": {
|
||||
"api": "API",
|
||||
"tutorials": "Examples"
|
||||
},
|
||||
"footerText": "Shared Editing",
|
||||
"css": [
|
||||
"./style.css"
|
||||
],
|
||||
"default": {
|
||||
"staticFiles": {
|
||||
"include": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"opts": {
|
||||
"destination": "./docs/",
|
||||
"encoding": "utf8",
|
||||
"private": false,
|
||||
"recurse": true,
|
||||
"template": "./node_modules/tui-jsdoc-template"
|
||||
}
|
||||
}
|
||||
4
.markdownlint.json
Normal file
4
.markdownlint.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"default": true,
|
||||
"no-inline-html": false
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* @flow */
|
||||
|
||||
type UserId = string
|
||||
type Id = [UserId, number|string]
|
||||
|
||||
/*
|
||||
type Struct = {
|
||||
id: Id,
|
||||
left?: Id,
|
||||
right?: Id,
|
||||
target?: Id,
|
||||
struct: 'Insert' | 'Delete'
|
||||
}*/
|
||||
|
||||
type Struct = Insertion | Deletion
|
||||
type Operation = Struct
|
||||
|
||||
type Insertion = {
|
||||
id: Id,
|
||||
left: ?Id,
|
||||
origin: ?Id,
|
||||
right: ?Id,
|
||||
parent: Id,
|
||||
parentSub: ?Id,
|
||||
opContent: ?Id,
|
||||
content: ?any,
|
||||
struct: 'Insert'
|
||||
}
|
||||
|
||||
type Deletion = {
|
||||
target: Id,
|
||||
struct: 'Delete'
|
||||
}
|
||||
|
||||
type MapStruct = {
|
||||
id: Id,
|
||||
type: TypeNames,
|
||||
map: any
|
||||
}
|
||||
|
||||
type ListStruct = {
|
||||
id: Id,
|
||||
type: TypeNames,
|
||||
start: Id,
|
||||
end: Id
|
||||
}
|
||||
|
||||
|
||||
type MessageSyncStep1 = {
|
||||
type: 'sync step 1',
|
||||
deleteSet: any,
|
||||
stateSet: any
|
||||
}
|
||||
|
||||
type MessageSyncStep2 = {
|
||||
type: 'sync step 2',
|
||||
os: Array<Operation>,
|
||||
deleteSet: any,
|
||||
stateSet: any
|
||||
}
|
||||
|
||||
type MessageUpdate = {
|
||||
type: 'update',
|
||||
ops: Array<Operation>
|
||||
}
|
||||
|
||||
type MessageSyncDone = {
|
||||
type: 'sync done'
|
||||
}
|
||||
|
||||
type Message = MessageSyncStep1 | MessageSyncStep2 | MessageUpdate | MessageSyncDone
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/* @flow */
|
||||
|
||||
type YGlobal = {
|
||||
utils: Object,
|
||||
Struct: any,
|
||||
AbstractDatabase: any,
|
||||
AbstractConnector: any,
|
||||
Transaction: any
|
||||
}
|
||||
|
||||
type YConfig = {
|
||||
db: Object,
|
||||
connector: Object,
|
||||
root: Object
|
||||
}
|
||||
|
||||
type TypeName = 'array' | 'map' | 'text'
|
||||
|
||||
declare var YConcurrency_TestingMode : boolean
|
||||
|
||||
type Transaction<A> = Generator<any, A, any>
|
||||
|
||||
type SyncRole = 'master' | 'slave'
|
||||
|
||||
declare class Store {
|
||||
find: (id:Id) => Transaction<any>;
|
||||
put: (n:any) => Transaction<void>;
|
||||
delete: (id:Id) => Transaction<void>;
|
||||
findWithLowerBound: (start:Id) => Transaction<any>;
|
||||
findWithUpperBound: (end:Id) => Transaction<any>;
|
||||
findNext: (id:Id) => Transaction<any>;
|
||||
findPrev: (id:Id) => Transaction<any>;
|
||||
iterate: (t:any,start:?Id,end:?Id,gen:any) => Transaction<any>;
|
||||
}
|
||||
1
dist
1
dist
Submodule dist deleted from 8739fd3a9c
@@ -1,214 +0,0 @@
|
||||
|
||||
var $ = require('gulp-load-plugins')()
|
||||
var minimist = require('minimist')
|
||||
var browserify = require('browserify')
|
||||
var source = require('vinyl-source-stream')
|
||||
var buffer = require('vinyl-buffer')
|
||||
|
||||
module.exports = function (gulp, helperOptions) {
|
||||
var runSequence = require('run-sequence').use(gulp)
|
||||
var options = minimist(process.argv.slice(2), {
|
||||
string: ['modulename', 'export', 'name', 'port', 'testfiles', 'es6'],
|
||||
default: {
|
||||
moduleName: helperOptions.moduleName,
|
||||
targetName: helperOptions.targetName,
|
||||
export: 'ignore',
|
||||
port: '8888',
|
||||
testfiles: '**/*.spec.js',
|
||||
es6: false,
|
||||
browserify: helperOptions.browserify != null ? helperOptions.browserify : false,
|
||||
includeRuntime: helperOptions.includeRuntime || false,
|
||||
debug: false
|
||||
}
|
||||
})
|
||||
if (options.es6 !== false) {
|
||||
options.es6 = true
|
||||
}
|
||||
var files = {
|
||||
dist: helperOptions.entry,
|
||||
specs: helperOptions.specs,
|
||||
src: './src/**/*.js'
|
||||
}
|
||||
|
||||
if (options.includeRuntime) {
|
||||
files.distEs5 = ['node_modules/regenerator/runtime.js', files.dist]
|
||||
} else {
|
||||
files.distEs5 = [files.dist]
|
||||
}
|
||||
|
||||
var header = require('gulp-header')
|
||||
var banner = ['/**',
|
||||
' * <%= pkg.name %> - <%= pkg.description %>',
|
||||
' * @version v<%= pkg.version %>',
|
||||
' * @link <%= pkg.homepage %>',
|
||||
' * @license <%= pkg.license %>',
|
||||
' */',
|
||||
''].join('\n')
|
||||
|
||||
gulp.task('dist:es5', function () {
|
||||
var babelOptions = {
|
||||
presets: ['es2015']
|
||||
}
|
||||
return (browserify({
|
||||
entries: files.distEs5,
|
||||
debug: true,
|
||||
standalone: options.moduleName
|
||||
}).transform('babelify', babelOptions)
|
||||
.bundle()
|
||||
.pipe(source(options.targetName))
|
||||
.pipe(buffer())
|
||||
.pipe($.sourcemaps.init({loadMaps: true}))
|
||||
.pipe($.if(!options.debug, $.uglify().on('error', function (e) {
|
||||
console.log('\x07', e.message, JSON.stringify(e)); return this.end()
|
||||
})))
|
||||
.pipe(header(banner, { pkg: require('./package.json') }))
|
||||
.pipe($.sourcemaps.write('.'))
|
||||
.pipe(gulp.dest('./dist/')))
|
||||
})
|
||||
|
||||
gulp.task('dist:es6', function () {
|
||||
return (browserify({
|
||||
entries: files.dist,
|
||||
debug: true,
|
||||
standalone: options.moduleName
|
||||
}).bundle()
|
||||
.pipe(source(options.targetName))
|
||||
.pipe(buffer())
|
||||
.pipe($.sourcemaps.init({loadMaps: true}))
|
||||
// .pipe($.uglify()) -- generators not yet supported see #448
|
||||
.pipe($.rename({
|
||||
extname: '.es6'
|
||||
}))
|
||||
.pipe(header(banner, { pkg: require('./package.json') }))
|
||||
.pipe($.sourcemaps.write('.'))
|
||||
.pipe(gulp.dest('./dist/')))
|
||||
})
|
||||
|
||||
gulp.task('dist', ['dist:es6', 'dist:es5'])
|
||||
|
||||
gulp.task('watch:dist', function (cb) {
|
||||
options.debug = true
|
||||
gulp.src(['./README.md'])
|
||||
.pipe($.watch('./README.md'))
|
||||
.pipe(gulp.dest('./dist/'))
|
||||
runSequence('dist', function () {
|
||||
gulp.watch(files.src.concat('./README.md'), ['dist'])
|
||||
cb()
|
||||
})
|
||||
})
|
||||
|
||||
gulp.task('dev:node', ['test'], function () {
|
||||
gulp.watch(files.src, ['test'])
|
||||
})
|
||||
|
||||
gulp.task('spec-build', function () {
|
||||
var browserify = require('browserify')
|
||||
var source = require('vinyl-source-stream')
|
||||
var buffer = require('vinyl-buffer')
|
||||
|
||||
return browserify({
|
||||
entries: files.specs, // .concat(files.distEs5),
|
||||
debug: true
|
||||
})// .transform('babelify', { presets: ['es2015'] })
|
||||
.bundle()
|
||||
.pipe(source('specs.js'))
|
||||
.pipe(buffer())
|
||||
// .pipe($.sourcemaps.init({loadMaps: true}))
|
||||
// .pipe($.sourcemaps.write('.'))
|
||||
.pipe(gulp.dest('./build/'))
|
||||
})
|
||||
|
||||
gulp.task('dev:browser', ['spec-build'], function () {
|
||||
gulp.watch(files.src, ['spec-build'])
|
||||
return gulp.src('./build/specs.js')
|
||||
.pipe($.jasmineBrowser.specRunner())
|
||||
.pipe($.jasmineBrowser.server({port: options.port}))
|
||||
})
|
||||
|
||||
gulp.task('test', function () {
|
||||
return gulp.src(files.specs)
|
||||
.pipe($.jasmine({
|
||||
verbose: true,
|
||||
includeStuckTrace: true
|
||||
}))
|
||||
})
|
||||
|
||||
gulp.task('updateSubmodule', function () {
|
||||
return gulp.src('./package.json', {read: false})
|
||||
.pipe($.shell([
|
||||
'git submodule update --init',
|
||||
'cd dist && git pull origin dist'
|
||||
]))
|
||||
})
|
||||
|
||||
gulp.task('bump', function (cb) {
|
||||
gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
|
||||
.pipe($.prompt.prompt({
|
||||
type: 'checkbox',
|
||||
name: 'bump',
|
||||
message: 'What type of bump would you like to do?',
|
||||
choices: ['patch', 'minor', 'major']
|
||||
}, function (res) {
|
||||
if (res.bump.length === 0) {
|
||||
console.info('You have to select a bump type. Now I\'m going to use "patch" as bump type..')
|
||||
}
|
||||
var bumptype = res.bump[0]
|
||||
if (bumptype === 'major') {
|
||||
runSequence('bump_major', cb)
|
||||
} else if (bumptype === 'minor') {
|
||||
runSequence('bump_minor', cb)
|
||||
} else {
|
||||
runSequence('bump_patch', cb)
|
||||
}
|
||||
}))
|
||||
})
|
||||
gulp.task('bump_patch', function () {
|
||||
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
|
||||
.pipe($.bump({type: 'patch'}))
|
||||
.pipe(gulp.dest('./'))
|
||||
})
|
||||
gulp.task('bump_minor', function () {
|
||||
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
|
||||
.pipe($.bump({type: 'minor'}))
|
||||
.pipe(gulp.dest('./'))
|
||||
})
|
||||
gulp.task('bump_major', function () {
|
||||
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
|
||||
.pipe($.bump({type: 'major'}))
|
||||
.pipe(gulp.dest('./'))
|
||||
})
|
||||
|
||||
gulp.task('publish_commits', function () {
|
||||
return gulp.src('./package.json')
|
||||
.pipe($.prompt.confirm({
|
||||
message: 'Are you sure you want to publish this release?',
|
||||
default: false
|
||||
}))
|
||||
.pipe($.shell([
|
||||
'cp README.md dist',
|
||||
'standard',
|
||||
'echo "Deploying version <%= getVersion(file.path) %>"',
|
||||
'git pull',
|
||||
'cd ./dist/ && git add -A',
|
||||
'cd ./dist/ && git commit -am "Deploy <%= getVersion(file.path) %>" -n',
|
||||
'cd ./dist/ && git push origin HEAD:dist',
|
||||
'cd ./dist/ && git tag -a v<%= getVersion(file.path) %> -m "Release <%= getVersion(file.path) %>"',
|
||||
'cd ./dist/ && git push origin --tags',
|
||||
'git commit -am "Release <%= getVersion(file.path) %>" -n',
|
||||
'git push',
|
||||
'npm publish',
|
||||
'echo Finished'
|
||||
], {
|
||||
templateData: {
|
||||
getVersion: function () {
|
||||
return JSON.parse(String.fromCharCode.apply(null, this.file._contents)).version
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
gulp.task('publish', function (cb) {
|
||||
/* TODO: include 'test',*/
|
||||
runSequence('updateSubmodule', 'bump', 'dist', 'publish_commits', cb)
|
||||
})
|
||||
}
|
||||
104
gulpfile.js
104
gulpfile.js
@@ -1,104 +0,0 @@
|
||||
/* eslint-env node */
|
||||
|
||||
/** Gulp Commands
|
||||
|
||||
gulp command*
|
||||
[--export ModuleType]
|
||||
[--name ModuleName]
|
||||
[--testport TestPort]
|
||||
[--testfiles TestFiles]
|
||||
|
||||
Module name (ModuleName):
|
||||
Compile this to "y.js" (default)
|
||||
|
||||
Supported module types (ModuleType):
|
||||
- amd
|
||||
- amdStrict
|
||||
- common
|
||||
- commonStrict
|
||||
- ignore (default)
|
||||
- system
|
||||
- umd
|
||||
- umdStrict
|
||||
|
||||
Test port (TestPort):
|
||||
Serve the specs on port 8888 (default)
|
||||
|
||||
Test files (TestFiles):
|
||||
Specify which specs to use!
|
||||
|
||||
Commands:
|
||||
- build:deploy
|
||||
Build this library for deployment (es6->es5, minified)
|
||||
- dev:browser
|
||||
Watch the ./src directory.
|
||||
Builds the library on changes.
|
||||
Starts an http-server and serves the test suite on http://127.0.0.1:8888.
|
||||
- dev:node
|
||||
Watch the ./src directory.
|
||||
Builds and specs the library on changes.
|
||||
Usefull to run with node-inspector.
|
||||
`node-debug $(which gulp) dev:node
|
||||
- test:
|
||||
Test this library
|
||||
*/
|
||||
|
||||
var gulp = require('gulp')
|
||||
var $ = require('gulp-load-plugins')()
|
||||
var runSequence = require('run-sequence').use(gulp)
|
||||
|
||||
require('./gulpfile.helper.js')(gulp, {
|
||||
polyfills: [],
|
||||
entry: './src/y.js',
|
||||
targetName: 'y.js',
|
||||
moduleName: 'Y',
|
||||
includeRuntime: true,
|
||||
specs: [
|
||||
'./src/Database.spec.js',
|
||||
'../y-array/src/Array.spec.js',
|
||||
'../y-map/src/Map.spec.js'
|
||||
]
|
||||
})
|
||||
|
||||
gulp.task('dev:examples', ['watch:dist'], function () {
|
||||
// watch all distfiles and copy them to bower_components
|
||||
var distfiles = ['./dist/*.{js,es6}', './dist/*.{js,es6}.map', '../y-*/dist/*.{js,es6}', '../y-*/dist/*.{js,es6}.map']
|
||||
gulp.src(distfiles)
|
||||
.pipe($.watch(distfiles))
|
||||
.pipe($.rename(function (path) {
|
||||
var dir = path.dirname.split(/[\\\/]/)[0]
|
||||
console.log(JSON.stringify(path))
|
||||
path.dirname = dir === '.' ? 'yjs' : dir
|
||||
}))
|
||||
.pipe(gulp.dest('./dist/Examples/bower_components/'))
|
||||
|
||||
return $.serve('dist/Examples/')()
|
||||
})
|
||||
|
||||
gulp.task('default', ['updateSubmodule'], function (cb) {
|
||||
gulp.src('package.json')
|
||||
.pipe($.prompt.prompt({
|
||||
type: 'checkbox',
|
||||
name: 'tasks',
|
||||
message: 'Which tasks would you like to run?',
|
||||
choices: [
|
||||
'test Test this project',
|
||||
'dev:examples Serve the examples directory in ./dist/',
|
||||
'dev:browser Watch files & serve the testsuite for the browser',
|
||||
'dev:nodejs Watch filse & test this project with nodejs',
|
||||
'bump Bump the current state of the project',
|
||||
'publish Publish this project. Creates a github tag',
|
||||
'dist Build the distribution files'
|
||||
]
|
||||
}, function (res) {
|
||||
var tasks = res.tasks.map(function (task) {
|
||||
return task.split(' ')[0]
|
||||
})
|
||||
if (tasks.length > 0) {
|
||||
console.info('gulp ' + tasks.join(' '))
|
||||
runSequence(tasks, cb)
|
||||
} else {
|
||||
console.info('Ok, .. goodbye')
|
||||
}
|
||||
}))
|
||||
})
|
||||
2910
package-lock.json
generated
Normal file
2910
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
120
package.json
120
package.json
@@ -1,82 +1,80 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "12.3.3",
|
||||
"description": "A framework for real-time p2p shared editing on any data",
|
||||
"main": "./src/y.js",
|
||||
"scripts": {
|
||||
"test": "node --harmony ./node_modules/.bin/gulp test",
|
||||
"lint": "./node_modules/.bin/standard"
|
||||
"version": "13.3.2",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.cjs",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"unpkg": "./dist/yjs.mjs",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"pre-commit": [
|
||||
"lint",
|
||||
"test"
|
||||
"scripts": {
|
||||
"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 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
|
||||
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
"src/*",
|
||||
"tests/*",
|
||||
"docs/*"
|
||||
],
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
"test": "tests"
|
||||
},
|
||||
"standard": {
|
||||
"parser": "babel-eslint",
|
||||
"ignore": [
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"declarations/**",
|
||||
"./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-eslint": "^5.0.0-beta6",
|
||||
"babel-plugin-transform-runtime": "^6.1.18",
|
||||
"babel-preset-es2015": "^6.1.18",
|
||||
"babelify": "^7.2.0",
|
||||
"browserify": "^12.0.1",
|
||||
"eslint": "^1.10.2",
|
||||
"gulp": "^3.9.0",
|
||||
"gulp-bump": "^1.0.0",
|
||||
"gulp-concat": "^2.6.0",
|
||||
"gulp-filter": "^3.0.1",
|
||||
"gulp-git": "^1.6.0",
|
||||
"gulp-header": "^1.8.8",
|
||||
"gulp-if": "^2.0.0",
|
||||
"gulp-jasmine": "^2.0.1",
|
||||
"gulp-jasmine-browser": "^0.2.3",
|
||||
"gulp-load-plugins": "^1.0.0",
|
||||
"gulp-prompt": "^0.1.2",
|
||||
"gulp-rename": "^1.2.2",
|
||||
"gulp-serve": "^1.2.0",
|
||||
"gulp-shell": "^0.5.1",
|
||||
"gulp-sourcemaps": "^1.5.2",
|
||||
"gulp-tag-version": "^1.3.0",
|
||||
"gulp-uglify": "^2.0.0",
|
||||
"gulp-util": "^3.0.6",
|
||||
"gulp-watch": "^4.3.5",
|
||||
"minimist": "^1.2.0",
|
||||
"pre-commit": "^1.1.1",
|
||||
"regenerator": "^0.8.42",
|
||||
"run-sequence": "^1.1.4",
|
||||
"seedrandom": "^2.4.2",
|
||||
"standard": "^5.2.2",
|
||||
"vinyl-buffer": "^1.0.0",
|
||||
"vinyl-source-stream": "^1.1.0"
|
||||
"url": "https://github.com/yjs/yjs/issues"
|
||||
},
|
||||
"homepage": "https://yjs.dev",
|
||||
"dependencies": {
|
||||
"debug": "^2.6.3"
|
||||
"lib0": "^0.2.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^11.1.0",
|
||||
"@rollup/plugin-node-resolve": "^7.1.3",
|
||||
"concurrently": "^3.6.1",
|
||||
"http-server": "^0.12.3",
|
||||
"jsdoc": "^3.6.4",
|
||||
"markdownlint-cli": "^0.23.2",
|
||||
"rollup": "^1.32.1",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"standard": "^14.3.4",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.9.6",
|
||||
"y-protocols": "^0.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
94
rollup.config.js
Normal file
94
rollup.config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
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
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.js',
|
||||
format: 'iife',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
mainFields: ['module', 'main']
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
external: ['isomorphic.js']
|
||||
}]
|
||||
485
src/Connector.js
485
src/Connector.js
@@ -1,485 +0,0 @@
|
||||
function canRead (auth) { return auth === 'read' || auth === 'write' }
|
||||
function canWrite (auth) { return auth === 'write' }
|
||||
|
||||
module.exports = function (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
|
||||
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.syncStep2 = Promise.resolve()
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
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
|
||||
if (this.y.db.transactionInProgress) {
|
||||
this.y.db.whenTransactionsFinished().then(broadcastOperations)
|
||||
} else {
|
||||
setTimeout(broadcastOperations, 0)
|
||||
}
|
||||
} 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('Incompatible protocol version')
|
||||
}
|
||||
if (message.auth != null && this.connections[sender] != null) {
|
||||
// authenticate using auth in message
|
||||
var auth = this.checkAuth(message.auth, this.y, sender)
|
||||
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, sender)
|
||||
}
|
||||
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
|
||||
|
||||
this.y.db.requestTransaction(function *() {
|
||||
var currentStateSet = yield* this.getStateSet()
|
||||
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
|
||||
}
|
||||
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
|
||||
var defer = {}
|
||||
defer.promise = new Promise(function (resolve) {
|
||||
defer.resolve = resolve
|
||||
})
|
||||
this.syncStep2 = defer.promise
|
||||
let m /* :MessageSyncStep2 */ = message
|
||||
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)
|
||||
}
|
||||
/*
|
||||
* This just sends the complete hb after some time
|
||||
* Mostly for debugging..
|
||||
*
|
||||
db.requestTransaction(function * () {
|
||||
var ops = yield* this.getOperations(m.stateSet)
|
||||
if (ops.length > 0) {
|
||||
if (!broadcastHB) { // TODO: consider to broadcast here..
|
||||
conn.send(sender, {
|
||||
type: 'update',
|
||||
ops: ops
|
||||
})
|
||||
} else {
|
||||
// broadcast only once!
|
||||
conn.broadcastOps(ops)
|
||||
}
|
||||
}
|
||||
})
|
||||
*/
|
||||
defer.resolve()
|
||||
})
|
||||
} else if (message.type === 'sync done') {
|
||||
var self = this
|
||||
this.syncStep2.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('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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
604
src/Database.js
604
src/Database.js
@@ -1,604 +0,0 @@
|
||||
/* @flow */
|
||||
'use strict'
|
||||
|
||||
module.exports = function (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
|
||||
this.dbOpts = opts
|
||||
var os = this
|
||||
this.userId = null
|
||||
var resolve
|
||||
this.userIdPromise = new Promise(function (r) {
|
||||
resolve = r
|
||||
})
|
||||
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 YConcurrency_TestingMode !== '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.gc1.length > 0 || os.gc2.length > 0) {
|
||||
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 == null || 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 YConcurrency_TestingMode !== '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 () {
|
||||
clearInterval(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 (r) {
|
||||
resolve = r
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
12
src/Notes.md
12
src/Notes.md
@@ -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)
|
||||
*
|
||||
@@ -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.YConcurrency_TestingMode = 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
|
||||
414
src/Struct.js
414
src/Struct.js
@@ -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.
|
||||
*/
|
||||
module.exports = function (Y/* :any */) {
|
||||
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
|
||||
}
|
||||
1098
src/Transaction.js
1098
src/Transaction.js
File diff suppressed because it is too large
Load Diff
795
src/Utils.js
795
src/Utils.js
@@ -1,795 +0,0 @@
|
||||
/* @flow */
|
||||
'use strict'
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
module.exports = function (Y /* : any*/) {
|
||||
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
|
||||
}
|
||||
74
src/index.js
Normal file
74
src/index.js
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
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,
|
||||
YTextEvent,
|
||||
YEvent,
|
||||
Item,
|
||||
AbstractStruct,
|
||||
GC,
|
||||
ContentBinary,
|
||||
ContentDeleted,
|
||||
ContentEmbed,
|
||||
ContentFormat,
|
||||
ContentJSON,
|
||||
ContentAny,
|
||||
ContentString,
|
||||
ContentType,
|
||||
AbstractType,
|
||||
RelativePosition,
|
||||
getTypeChildren,
|
||||
createRelativePositionFromTypeIndex,
|
||||
createRelativePositionFromJSON,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
compareRelativePositions,
|
||||
writeRelativePosition,
|
||||
readRelativePosition,
|
||||
ID,
|
||||
createID,
|
||||
compareIDs,
|
||||
getState,
|
||||
Snapshot,
|
||||
createSnapshot,
|
||||
createDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
snapshot,
|
||||
emptySnapshot,
|
||||
findRootTypeKey,
|
||||
typeListToArraySnapshot,
|
||||
typeMapGetSnapshot,
|
||||
iterateDeletedStructs,
|
||||
applyUpdate,
|
||||
applyUpdateV2,
|
||||
readUpdate,
|
||||
readUpdateV2,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateAsUpdateV2,
|
||||
encodeStateVector,
|
||||
encodeStateVectorV2,
|
||||
UndoManager,
|
||||
decodeSnapshot,
|
||||
encodeSnapshot,
|
||||
decodeSnapshotV2,
|
||||
encodeSnapshotV2,
|
||||
decodeStateVector,
|
||||
decodeStateVectorV2,
|
||||
isDeleted,
|
||||
isParentOf,
|
||||
equalSnapshots,
|
||||
PermanentUserData, // @TODO experimental
|
||||
tryGc,
|
||||
transact,
|
||||
AbstractConnector,
|
||||
logType
|
||||
} from './internals.js'
|
||||
40
src/internals.js
Normal file
40
src/internals.js
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
export * from './utils/AbstractConnector.js'
|
||||
export * from './utils/DeleteSet.js'
|
||||
export * from './utils/Doc.js'
|
||||
export * from './utils/UpdateDecoder.js'
|
||||
export * from './utils/UpdateEncoder.js'
|
||||
export * from './utils/encoding.js'
|
||||
export * from './utils/EventHandler.js'
|
||||
export * from './utils/ID.js'
|
||||
export * from './utils/isParentOf.js'
|
||||
export * from './utils/logging.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'
|
||||
52
src/structs/AbstractStruct.js
Normal file
52
src/structs/AbstractStruct.js
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import {
|
||||
AbstractUpdateEncoder, ID, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
export class AbstractStruct {
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor (id, length) {
|
||||
this.id = id
|
||||
this.length = length
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
get deleted () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
* @param {number} encodingRef
|
||||
*/
|
||||
write (encoder, offset, encodingRef) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
108
src/structs/ContentAny.js
Normal file
108
src/structs/ContentAny.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
export class ContentAny {
|
||||
/**
|
||||
* @param {Array<any>} arr
|
||||
*/
|
||||
constructor (arr) {
|
||||
/**
|
||||
* @type {Array<any>}
|
||||
*/
|
||||
this.arr = arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.arr.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentAny(this.arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
splice (offset) {
|
||||
const right = new ContentAny(this.arr.slice(offset))
|
||||
this.arr = this.arr.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentAny} right
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
this.arr = this.arr.concat(right.arr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
*/
|
||||
integrate (transaction, item) {}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {}
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const len = this.arr.length
|
||||
encoder.writeLen(len - offset)
|
||||
for (let i = offset; i < len; i++) {
|
||||
const c = this.arr[i]
|
||||
encoder.writeAny(c)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
export const readContentAny = decoder => {
|
||||
const len = decoder.readLen()
|
||||
const cs = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
cs.push(decoder.readAny())
|
||||
}
|
||||
return new ContentAny(cs)
|
||||
}
|
||||
92
src/structs/ContentBinary.js
Normal file
92
src/structs/ContentBinary.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeBuf(this.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentBinary}
|
||||
*/
|
||||
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())
|
||||
101
src/structs/ContentDeleted.js
Normal file
101
src/structs/ContentDeleted.js
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
import {
|
||||
addToDeleteSet,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
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.client, item.id.clock, this.len)
|
||||
item.markDeleted()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {}
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeLen(this.len - offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentDeleted}
|
||||
*/
|
||||
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())
|
||||
98
src/structs/ContentEmbed.js
Normal file
98
src/structs/ContentEmbed.js
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
import {
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Item, Transaction // eslint-disable-line
|
||||
} from '../internals.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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeJSON(this.embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentEmbed}
|
||||
*/
|
||||
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())
|
||||
103
src/structs/ContentFormat.js
Normal file
103
src/structs/ContentFormat.js
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
import {
|
||||
AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Item, StructStore, Transaction // eslint-disable-line
|
||||
} from '../internals.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) {
|
||||
// @todo searchmarker are currently unsupported for rich text documents
|
||||
/** @type {AbstractType<any>} */ (item.parent)._searchMarker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {}
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeKey(this.key)
|
||||
encoder.writeJSON(this.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
export const readContentFormat = decoder => new ContentFormat(decoder.readString(), decoder.readJSON())
|
||||
118
src/structs/ContentJSON.js
Normal file
118
src/structs/ContentJSON.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const len = this.arr.length
|
||||
encoder.writeLen(len - offset)
|
||||
for (let i = offset; i < len; i++) {
|
||||
const c = this.arr[i]
|
||||
encoder.writeString(c === undefined ? 'undefined' : JSON.stringify(c))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentJSON}
|
||||
*/
|
||||
export const readContentJSON = decoder => {
|
||||
const len = decoder.readLen()
|
||||
const cs = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
const c = decoder.readString()
|
||||
if (c === 'undefined') {
|
||||
cs.push(undefined)
|
||||
} else {
|
||||
cs.push(JSON.parse(c))
|
||||
}
|
||||
}
|
||||
return new ContentJSON(cs)
|
||||
}
|
||||
101
src/structs/ContentString.js
Normal file
101
src/structs/ContentString.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeString(offset === 0 ? this.str : this.str.slice(offset))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentString}
|
||||
*/
|
||||
export const readContentString = decoder => new ContentString(decoder.readString())
|
||||
172
src/structs/ContentType.js
Normal file
172
src/structs/ContentType.js
Normal file
@@ -0,0 +1,172 @@
|
||||
|
||||
import {
|
||||
readYArray,
|
||||
readYMap,
|
||||
readYText,
|
||||
readYXmlElement,
|
||||
readYXmlFragment,
|
||||
readYXmlHook,
|
||||
readYXmlText,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @type {Array<function(AbstractUpdateDecoder):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.push(item)
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
this.type._map.forEach(item => {
|
||||
if (!item.deleted) {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// same as above
|
||||
transaction._mergeStructs.push(item)
|
||||
}
|
||||
})
|
||||
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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
this.type._write(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 7
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {ContentType}
|
||||
*/
|
||||
export const readContentType = decoder => new ContentType(typeRefs[decoder.readTypeRef()](decoder))
|
||||
58
src/structs/GC.js
Normal file
58
src/structs/GC.js
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
import {
|
||||
AbstractStruct,
|
||||
addStruct,
|
||||
AbstractUpdateEncoder, StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
export const structGCRefNumber = 0
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class GC extends AbstractStruct {
|
||||
get deleted () {
|
||||
return true
|
||||
}
|
||||
|
||||
delete () {}
|
||||
|
||||
/**
|
||||
* @param {GC} right
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
this.length += right.length
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.length -= offset
|
||||
}
|
||||
addStruct(transaction.doc.store, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeInfo(structGCRefNumber)
|
||||
encoder.writeLen(this.length - offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @return {null | number}
|
||||
*/
|
||||
getMissing (transaction, store) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
775
src/structs/Item.js
Normal file
775
src/structs/Item.js
Normal file
@@ -0,0 +1,775 @@
|
||||
|
||||
import {
|
||||
GC,
|
||||
getState,
|
||||
AbstractStruct,
|
||||
replaceStruct,
|
||||
addStruct,
|
||||
addToDeleteSet,
|
||||
findRootTypeKey,
|
||||
compareIDs,
|
||||
getItem,
|
||||
getItemCleanEnd,
|
||||
getItemCleanStart,
|
||||
readContentDeleted,
|
||||
readContentBinary,
|
||||
readContentJSON,
|
||||
readContentAny,
|
||||
readContentString,
|
||||
readContentEmbed,
|
||||
createID,
|
||||
readContentFormat,
|
||||
readContentType,
|
||||
addChangedTypeToTransaction,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.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 = /** @type {AbstractType<any>} */ (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) => {
|
||||
// create rightItem
|
||||
const { client, clock } = leftItem.id
|
||||
const rightItem = new Item(
|
||||
createID(client, clock + diff),
|
||||
leftItem,
|
||||
createID(client, clock + diff - 1),
|
||||
leftItem.right,
|
||||
leftItem.rightOrigin,
|
||||
leftItem.parent,
|
||||
leftItem.parentSub,
|
||||
leftItem.content.splice(diff)
|
||||
)
|
||||
if (leftItem.deleted) {
|
||||
rightItem.markDeleted()
|
||||
}
|
||||
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.push(rightItem)
|
||||
// update parent._map
|
||||
if (rightItem.parentSub !== null && rightItem.right === null) {
|
||||
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
|
||||
}
|
||||
leftItem.length = diff
|
||||
return rightItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Redoes the effect of this operation.
|
||||
*
|
||||
* @param {Transaction} transaction The Yjs instance.
|
||||
* @param {Item} item
|
||||
* @param {Set<Item>} redoitems
|
||||
*
|
||||
* @return {Item|null}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const redoItem = (transaction, item, redoitems) => {
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ownClientID = doc.clientID
|
||||
const redone = item.redone
|
||||
if (redone !== null) {
|
||||
return getItemCleanStart(transaction, redone)
|
||||
}
|
||||
let parentItem = /** @type {AbstractType<any>} */ (item.parent)._item
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let right
|
||||
if (item.parentSub === null) {
|
||||
// Is an array item. Insert at the old position
|
||||
left = item.left
|
||||
right = item
|
||||
} else {
|
||||
// Is a map item. Insert as current value
|
||||
left = item
|
||||
while (left.right !== null) {
|
||||
left = left.right
|
||||
if (left.id.client !== ownClientID) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (left.right !== null) {
|
||||
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
|
||||
}
|
||||
right = null
|
||||
}
|
||||
// make sure that parent is redone
|
||||
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
|
||||
// try to undo parent if it will be undone anyway
|
||||
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (parentItem !== null && parentItem.redone !== null) {
|
||||
while (parentItem.redone !== null) {
|
||||
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||
}
|
||||
// find next cloned_redo items
|
||||
while (left !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let leftTrace = left
|
||||
// trace redone until parent matches
|
||||
while (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item !== parentItem) {
|
||||
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
|
||||
}
|
||||
if (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item === parentItem) {
|
||||
left = leftTrace
|
||||
break
|
||||
}
|
||||
left = left.left
|
||||
}
|
||||
while (right !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let rightTrace = right
|
||||
// trace redone until parent matches
|
||||
while (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item !== parentItem) {
|
||||
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
|
||||
}
|
||||
if (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item === parentItem) {
|
||||
right = rightTrace
|
||||
break
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
}
|
||||
const nextClock = getState(store, ownClientID)
|
||||
const nextId = createID(ownClientID, nextClock)
|
||||
const redoneItem = new Item(
|
||||
nextId,
|
||||
left, left && left.lastId,
|
||||
right, right && right.id,
|
||||
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
|
||||
item.parentSub,
|
||||
item.content.copy()
|
||||
)
|
||||
item.redone = nextId
|
||||
keepItem(redoneItem, true)
|
||||
redoneItem.integrate(transaction, 0)
|
||||
return redoneItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class that represents any content.
|
||||
*/
|
||||
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>|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it.
|
||||
* @param {string | null} parentSub
|
||||
* @param {AbstractContent} content
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
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.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.rightOrigin = rightOrigin
|
||||
/**
|
||||
* @type {AbstractType<any>|ID|null}
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
this.parentSub = parentSub
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* bit1: keep
|
||||
* bit2: countable
|
||||
* bit3: deleted
|
||||
* bit4: mark - mark node as fast-search-marker
|
||||
* @type {number} byte
|
||||
*/
|
||||
this.info = this.content.isCountable() ? binary.BIT2 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to mark the item as an indexed fast-search marker
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
set marker (isMarked) {
|
||||
if (((this.info & binary.BIT4) > 0) !== isMarked) {
|
||||
this.info ^= binary.BIT4
|
||||
}
|
||||
}
|
||||
|
||||
get marker () {
|
||||
return (this.info & binary.BIT4) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, do not garbage collect this Item.
|
||||
*/
|
||||
get keep () {
|
||||
return (this.info & binary.BIT1) > 0
|
||||
}
|
||||
|
||||
set keep (doKeep) {
|
||||
if (this.keep !== doKeep) {
|
||||
this.info ^= binary.BIT1
|
||||
}
|
||||
}
|
||||
|
||||
get countable () {
|
||||
return (this.info & binary.BIT2) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item was deleted or not.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
get deleted () {
|
||||
return (this.info & binary.BIT3) > 0
|
||||
}
|
||||
|
||||
set deleted (doDelete) {
|
||||
if (this.deleted !== doDelete) {
|
||||
this.info ^= binary.BIT3
|
||||
}
|
||||
}
|
||||
|
||||
markDeleted () {
|
||||
this.info |= binary.BIT3
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the creator clientID of the missing op or define missing items and return null.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @return {null | number}
|
||||
*/
|
||||
getMissing (transaction, store) {
|
||||
if (this.origin && this.origin.client !== this.id.client && this.origin.clock >= getState(store, this.origin.client)) {
|
||||
return this.origin.client
|
||||
}
|
||||
if (this.rightOrigin && this.rightOrigin.client !== this.id.client && this.rightOrigin.clock >= getState(store, this.rightOrigin.client)) {
|
||||
return this.rightOrigin.client
|
||||
}
|
||||
if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) {
|
||||
return this.parent.client
|
||||
}
|
||||
|
||||
// We have all missing ids, now find the items
|
||||
|
||||
if (this.origin) {
|
||||
this.left = getItemCleanEnd(transaction, store, this.origin)
|
||||
this.origin = this.left.lastId
|
||||
}
|
||||
if (this.rightOrigin) {
|
||||
this.right = getItemCleanStart(transaction, this.rightOrigin)
|
||||
this.rightOrigin = this.right.id
|
||||
}
|
||||
if ((this.left && this.left.constructor === GC) || (this.right && this.right.constructor === GC)) {
|
||||
this.parent = null
|
||||
}
|
||||
// only set parent if this shouldn't be garbage collected
|
||||
if (!this.parent) {
|
||||
if (this.left && this.left.constructor === Item) {
|
||||
this.parent = this.left.parent
|
||||
this.parentSub = this.left.parentSub
|
||||
}
|
||||
if (this.right && this.right.constructor === Item) {
|
||||
this.parent = this.right.parent
|
||||
this.parentSub = this.right.parentSub
|
||||
}
|
||||
} else if (this.parent.constructor === ID) {
|
||||
const parentItem = getItem(store, this.parent)
|
||||
if (parentItem.constructor === GC) {
|
||||
this.parent = null
|
||||
} else {
|
||||
this.parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1))
|
||||
this.origin = this.left.lastId
|
||||
this.content = this.content.splice(offset)
|
||||
this.length -= offset
|
||||
}
|
||||
|
||||
if (this.parent) {
|
||||
if ((!this.left && (!this.right || this.right.left !== null)) || (this.left && this.left.right !== this.right)) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left = this.left
|
||||
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (left !== null) {
|
||||
o = left.right
|
||||
} else if (this.parentSub !== null) {
|
||||
o = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
|
||||
while (o !== null && o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
} else {
|
||||
o = /** @type {AbstractType<any>} */ (this.parent)._start
|
||||
}
|
||||
// TODO: use something like DeleteSet here (a tree implementation would be best)
|
||||
// @todo use global set definitions
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const conflictingItems = new Set()
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this.right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (compareIDs(this.origin, o.origin)) {
|
||||
// case 1
|
||||
if (o.id.client < this.id.client) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
} else if (compareIDs(this.rightOrigin, o.rightOrigin)) {
|
||||
// this and o are conflicting and point to the same integration points. The id decides which item comes first.
|
||||
// Since this is to the left of o, we can break here
|
||||
break
|
||||
} // else, o might be integrated before an item that this conflicts with. If so, we will find it in the next iterations
|
||||
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) { // use getItem instead of getItemCleanEnd because we don't want / need to split items.
|
||||
// case 2
|
||||
if (!conflictingItems.has(getItem(transaction.doc.store, o.origin))) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
o = o.right
|
||||
}
|
||||
this.left = left
|
||||
}
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
if (this.left !== null) {
|
||||
const right = this.left.right
|
||||
this.right = right
|
||||
this.left.right = this
|
||||
} else {
|
||||
let r
|
||||
if (this.parentSub !== null) {
|
||||
r = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
|
||||
while (r !== null && r.left !== null) {
|
||||
r = r.left
|
||||
}
|
||||
} else {
|
||||
r = /** @type {AbstractType<any>} */ (this.parent)._start
|
||||
;/** @type {AbstractType<any>} */ (this.parent)._start = this
|
||||
}
|
||||
this.right = r
|
||||
}
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
} else if (this.parentSub !== null) {
|
||||
// set as current parent value if right === null and this is parentSub
|
||||
/** @type {AbstractType<any>} */ (this.parent)._map.set(this.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 (this.parentSub === null && this.countable && !this.deleted) {
|
||||
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
|
||||
}
|
||||
addStruct(transaction.doc.store, this)
|
||||
this.content.integrate(transaction, this)
|
||||
// add parent to transaction.changed
|
||||
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub)
|
||||
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
|
||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||
this.delete(transaction)
|
||||
}
|
||||
} else {
|
||||
// parent is not defined. Integrate GC struct instead
|
||||
new GC(this.id, this.length).integrate(transaction, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next non-deleted item
|
||||
*/
|
||||
get next () {
|
||||
let n = this.right
|
||||
while (n !== null && n.deleted) {
|
||||
n = n.right
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous non-deleted item
|
||||
*/
|
||||
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 () {
|
||||
// allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible
|
||||
return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to merge two items
|
||||
*
|
||||
* @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 = /** @type {AbstractType<any>} */ (this.parent)
|
||||
// adjust the length of parent
|
||||
if (this.countable && this.parentSub === null) {
|
||||
parent._length -= this.length
|
||||
}
|
||||
this.markDeleted()
|
||||
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
|
||||
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
|
||||
this.content.delete(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*/
|
||||
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 {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
*/
|
||||
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
|
||||
encoder.writeInfo(info)
|
||||
if (origin !== null) {
|
||||
encoder.writeLeftID(origin)
|
||||
}
|
||||
if (rightOrigin !== null) {
|
||||
encoder.writeRightID(rightOrigin)
|
||||
}
|
||||
if (origin === null && rightOrigin === null) {
|
||||
const parent = /** @type {AbstractType<any>} */ (this.parent)
|
||||
const parentItem = parent._item
|
||||
if (parentItem === null) {
|
||||
// parent type on y._map
|
||||
// find the correct key
|
||||
const ykey = findRootTypeKey(parent)
|
||||
encoder.writeParentInfo(true) // write parentYKey
|
||||
encoder.writeString(ykey)
|
||||
} else {
|
||||
encoder.writeParentInfo(false) // write parent id
|
||||
encoder.writeLeftID(parentItem.id)
|
||||
}
|
||||
if (parentSub !== null) {
|
||||
encoder.writeString(parentSub)
|
||||
}
|
||||
}
|
||||
this.content.write(encoder, offset)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @param {number} info
|
||||
*/
|
||||
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
|
||||
|
||||
/**
|
||||
* A lookup map for reading Item content.
|
||||
*
|
||||
* @type {Array<function(AbstractUpdateDecoder):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 {AbstractUpdateEncoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
845
src/types/AbstractType.js
Normal file
845
src/types/AbstractType.js
Normal file
@@ -0,0 +1,845 @@
|
||||
|
||||
import {
|
||||
removeEventHandlerListener,
|
||||
callEventHandlerListeners,
|
||||
addEventHandlerListener,
|
||||
createEventHandler,
|
||||
getState,
|
||||
isVisible,
|
||||
ContentType,
|
||||
createID,
|
||||
ContentAny,
|
||||
ContentBinary,
|
||||
getItemCleanStart,
|
||||
YText, YArray, AbstractUpdateEncoder, 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 math from 'lib0/math.js'
|
||||
|
||||
const maxSearchMarker = 80
|
||||
|
||||
/**
|
||||
* A unique timestamp that identifies each marker.
|
||||
*
|
||||
* Time is relative,.. this is more like an ever-increasing clock.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
let globalSearchMarkerTimestamp = 0
|
||||
|
||||
export class ArraySearchMarker {
|
||||
/**
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor (p, index) {
|
||||
p.marker = true
|
||||
this.p = p
|
||||
this.index = index
|
||||
this.timestamp = globalSearchMarkerTimestamp++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArraySearchMarker} marker
|
||||
*/
|
||||
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }
|
||||
|
||||
/**
|
||||
* This is rather complex so this function is the only thing that should overwrite a marker
|
||||
*
|
||||
* @param {ArraySearchMarker} marker
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
const overwriteMarker = (marker, p, index) => {
|
||||
marker.p.marker = false
|
||||
marker.p = p
|
||||
p.marker = true
|
||||
marker.index = index
|
||||
marker.timestamp = globalSearchMarkerTimestamp++
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<ArraySearchMarker>} searchMarker
|
||||
* @param {Item} p
|
||||
* @param {number} index
|
||||
*/
|
||||
const markPosition = (searchMarker, p, index) => {
|
||||
if (searchMarker.length >= maxSearchMarker) {
|
||||
// override oldest marker (we don't want to create more objects)
|
||||
const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
|
||||
overwriteMarker(marker, p, index)
|
||||
return marker
|
||||
} else {
|
||||
// create new marker
|
||||
const pm = new ArraySearchMarker(p, index)
|
||||
searchMarker.push(pm)
|
||||
return pm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search marker help us to find positions in the associative array faster.
|
||||
*
|
||||
* They speed up the process of finding a position without much bookkeeping.
|
||||
*
|
||||
* A maximum of `maxSearchMarker` objects are created.
|
||||
*
|
||||
* This function always returns a refreshed marker (updated timestamp)
|
||||
*
|
||||
* @param {AbstractType<any>} yarray
|
||||
* @param {number} index
|
||||
*/
|
||||
export const findMarker = (yarray, index) => {
|
||||
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
|
||||
return null
|
||||
}
|
||||
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
|
||||
let p = yarray._start
|
||||
let pindex = 0
|
||||
if (marker !== null) {
|
||||
p = marker.p
|
||||
pindex = marker.index
|
||||
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
|
||||
}
|
||||
// iterate to right if possible
|
||||
while (p.right !== null && pindex < index) {
|
||||
if (!p.deleted && p.countable) {
|
||||
if (index < pindex + p.length) {
|
||||
break
|
||||
}
|
||||
pindex += p.length
|
||||
}
|
||||
p = p.right
|
||||
}
|
||||
// iterate to left if necessary (might be that pindex > index)
|
||||
while (p.left !== null && pindex > index) {
|
||||
p = p.left
|
||||
if (!p.deleted && p.countable) {
|
||||
pindex -= p.length
|
||||
}
|
||||
}
|
||||
// we want to make sure that p can't be merged with left, because that would screw up everything
|
||||
// in that cas just return what we have (it is most likely the best marker anyway)
|
||||
// iterate to left until p can't be merged with left
|
||||
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
|
||||
p = p.left
|
||||
if (!p.deleted && p.countable) {
|
||||
pindex -= p.length
|
||||
}
|
||||
}
|
||||
|
||||
// @todo remove!
|
||||
// assure position
|
||||
// {
|
||||
// let start = yarray._start
|
||||
// let pos = 0
|
||||
// while (start !== p) {
|
||||
// if (!start.deleted && start.countable) {
|
||||
// pos += start.length
|
||||
// }
|
||||
// start = /** @type {Item} */ (start.right)
|
||||
// }
|
||||
// if (pos !== pindex) {
|
||||
// debugger
|
||||
// throw new Error('Gotcha position fail!')
|
||||
// }
|
||||
// }
|
||||
// if (marker) {
|
||||
// if (window.lengthes == null) {
|
||||
// window.lengthes = []
|
||||
// window.getLengthes = () => window.lengthes.sort((a, b) => a - b)
|
||||
// }
|
||||
// window.lengthes.push(marker.index - pindex)
|
||||
// console.log('distance', marker.index - pindex, 'len', p && p.parent.length)
|
||||
// }
|
||||
if (marker !== null && math.abs(marker.index - pindex) < /** @type {YText|YArray<any>} */ (p.parent).length / maxSearchMarker) {
|
||||
// adjust existing marker
|
||||
overwriteMarker(marker, p, pindex)
|
||||
return marker
|
||||
} else {
|
||||
// create new marker
|
||||
return markPosition(yarray._searchMarker, p, pindex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update markers when a change happened.
|
||||
*
|
||||
* This should be called before doing a deletion!
|
||||
*
|
||||
* @param {Array<ArraySearchMarker>} searchMarker
|
||||
* @param {number} index
|
||||
* @param {number} len If insertion, len is positive. If deletion, len is negative.
|
||||
*/
|
||||
export const updateMarkerChanges = (searchMarker, index, len) => {
|
||||
for (let i = searchMarker.length - 1; i >= 0; i--) {
|
||||
const m = searchMarker[i]
|
||||
if (len > 0) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let p = m.p
|
||||
p.marker = false
|
||||
// Ideally we just want to do a simple position comparison, but this will only work if
|
||||
// search markers don't point to deleted items for formats.
|
||||
// Iterate marker to prev undeleted countable position so we know what to do when updating a position
|
||||
while (p && (p.deleted || !p.countable)) {
|
||||
p = p.left
|
||||
if (p && !p.deleted && p.countable) {
|
||||
// adjust position. the loop should break now
|
||||
m.index -= p.length
|
||||
}
|
||||
}
|
||||
if (p === null || p.marker === true) {
|
||||
// remove search marker if updated position is null or if position is already marked
|
||||
searchMarker.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
m.p = p
|
||||
p.marker = true
|
||||
}
|
||||
if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
|
||||
m.index = math.max(index, m.index + len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate all (list) children of a type and return them as an Array.
|
||||
*
|
||||
* @param {AbstractType<any>} t
|
||||
* @return {Array<Item>}
|
||||
*/
|
||||
export const getTypeChildren = t => {
|
||||
let s = t._start
|
||||
const arr = []
|
||||
while (s) {
|
||||
arr.push(s)
|
||||
s = s.right
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* Call event listeners with an event. This will also add an event to all
|
||||
* parents (for `.observeDeep` handlers).
|
||||
*
|
||||
* @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 {AbstractType<any>} */ (type._item.parent)
|
||||
}
|
||||
callEventHandlerListeners(changedType._eH, event, transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
* @template EventType
|
||||
* Abstract Yjs Type class
|
||||
*/
|
||||
export class AbstractType {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
this._item = null
|
||||
/**
|
||||
* @type {Map<string,Item>}
|
||||
*/
|
||||
this._map = new Map()
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
this._start = null
|
||||
/**
|
||||
* @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()
|
||||
/**
|
||||
* @type {null | Array<ArraySearchMarker>}
|
||||
*/
|
||||
this._searchMarker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
this.doc = y
|
||||
this._item = item
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {AbstractType<EventType>}
|
||||
*/
|
||||
_copy () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_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.
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
if (!transaction.local && this._searchMarker) {
|
||||
this._searchMarker.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const marker = findMarker(type, index)
|
||||
let n = type._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
}
|
||||
for (; 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 doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
const store = doc.store
|
||||
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(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
|
||||
left.integrate(transaction, 0)
|
||||
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(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
|
||||
left.integrate(transaction, 0)
|
||||
break
|
||||
default:
|
||||
if (c instanceof AbstractType) {
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
|
||||
left.integrate(transaction, 0)
|
||||
} else {
|
||||
throw new Error('Unexpected content type in insert operation')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
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) {
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, index, content.length)
|
||||
}
|
||||
return typeListInsertGenericsAfter(transaction, parent, null, content)
|
||||
}
|
||||
const startIndex = index
|
||||
const marker = findMarker(parent, index)
|
||||
let n = parent._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
// we need to iterate one to the left so that the algorithm works
|
||||
if (index === 0) {
|
||||
// @todo refactor this as it actually doesn't consider formats
|
||||
n = n.prev // important! get the left undeleted item so that we can actually decrease index
|
||||
index += (n && n.countable && !n.deleted) ? n.length : 0
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, startIndex, content.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 }
|
||||
const startIndex = index
|
||||
const startLength = length
|
||||
const marker = findMarker(parent, index)
|
||||
let n = parent._start
|
||||
if (marker !== null) {
|
||||
n = marker.p
|
||||
index -= marker.index
|
||||
}
|
||||
// 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')
|
||||
}
|
||||
if (parent._searchMarker) {
|
||||
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
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(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = {}
|
||||
parent._map.forEach((value, key) => {
|
||||
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)
|
||||
222
src/types/YArray.js
Normal file
222
src/types/YArray.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @module YArray
|
||||
*/
|
||||
|
||||
import {
|
||||
YEvent,
|
||||
AbstractType,
|
||||
typeListGet,
|
||||
typeListToArray,
|
||||
typeListForEach,
|
||||
typeListCreateIterator,
|
||||
typeListInsertGenerics,
|
||||
typeListDelete,
|
||||
typeListMap,
|
||||
YArrayRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
ArraySearchMarker, AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
|
||||
} from '../internals.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 {Iterable<T>}
|
||||
*/
|
||||
export class YArray extends AbstractType {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<any>?}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = []
|
||||
/**
|
||||
* @type {Array<ArraySearchMarker>}
|
||||
*/
|
||||
this._searchMarker = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
* * Save this struct in the os
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new 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.
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
super._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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preppends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to preppend.
|
||||
*/
|
||||
unshift (content) {
|
||||
this.insert(0, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
* @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 {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YArrayRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYArray = decoder => new YArray()
|
||||
243
src/types/YMap.js
Normal file
243
src/types/YMap.js
Normal file
@@ -0,0 +1,243 @@
|
||||
|
||||
/**
|
||||
* @module YMap
|
||||
*/
|
||||
|
||||
import {
|
||||
YEvent,
|
||||
AbstractType,
|
||||
typeMapDelete,
|
||||
typeMapSet,
|
||||
typeMapGet,
|
||||
typeMapHas,
|
||||
createMapIterator,
|
||||
YMapRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, Transaction, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
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 {Iterable<T>}
|
||||
*/
|
||||
export class YMap extends AbstractType {
|
||||
/**
|
||||
*
|
||||
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
|
||||
*/
|
||||
constructor (entries) {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,any>?}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = null
|
||||
|
||||
if (entries === undefined) {
|
||||
this._prelimContent = new Map()
|
||||
} else {
|
||||
this._prelimContent = new Map(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
* * Save this struct in the os
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
;/** @type {Map<string, any>} */ (this._prelimContent).forEach((value, key) => {
|
||||
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.
|
||||
*/
|
||||
_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 = {}
|
||||
this._map.forEach((item, key) => {
|
||||
if (!item.deleted) {
|
||||
const v = item.content.getContent()[item.length - 1]
|
||||
map[key] = v instanceof AbstractType ? v.toJSON() : v
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the YMap (count of key/value pairs)
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
get size () {
|
||||
return [...createMapIterator(this._map)].length
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the keys for each element in the YMap Type.
|
||||
*
|
||||
* @return {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 every key-value pair.
|
||||
*
|
||||
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
|
||||
*/
|
||||
forEach (f) {
|
||||
/**
|
||||
* @type {Object<string,T>}
|
||||
*/
|
||||
const map = {}
|
||||
this._map.forEach((item, key) => {
|
||||
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 {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YMapRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYMap = decoder => new YMap()
|
||||
1120
src/types/YText.js
Normal file
1120
src/types/YText.js
Normal file
File diff suppressed because it is too large
Load Diff
195
src/types/YXmlElement.js
Normal file
195
src/types/YXmlElement.js
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import {
|
||||
YXmlFragment,
|
||||
transact,
|
||||
typeMapDelete,
|
||||
typeMapSet,
|
||||
typeMapGet,
|
||||
typeMapGetAll,
|
||||
typeListForEach,
|
||||
YXmlElementRefID,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Snapshot, Doc, Item // eslint-disable-line
|
||||
} from '../internals.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}
|
||||
*/
|
||||
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
|
||||
*/
|
||||
_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}
|
||||
*/
|
||||
_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.
|
||||
*
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YXmlElementRefID)
|
||||
encoder.writeKey(this.nodeName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlElement}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlElement = decoder => new YXmlElement(decoder.readKey())
|
||||
39
src/types/YXmlEvent.js
Normal file
39
src/types/YXmlEvent.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
339
src/types/YXmlFragment.js
Normal file
339
src/types/YXmlFragment.js
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* @module YXml
|
||||
*/
|
||||
|
||||
import {
|
||||
YXmlEvent,
|
||||
YXmlElement,
|
||||
AbstractType,
|
||||
typeListMap,
|
||||
typeListForEach,
|
||||
typeListInsertGenerics,
|
||||
typeListDelete,
|
||||
typeListToArray,
|
||||
YXmlFragmentRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* Define the elements to which a set of CSS queries apply.
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
|
||||
*
|
||||
* @example
|
||||
* query = '.classSelector'
|
||||
* query = 'nodeSelector'
|
||||
* query = '#idSelector'
|
||||
*
|
||||
* @typedef {string} CSS_Selector
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dom filter function.
|
||||
*
|
||||
* @callback domFilter
|
||||
* @param {string} nodeName The nodeName of the element
|
||||
* @param {Map} attributes The map of attributes.
|
||||
* @return {boolean} Whether to include the Dom node in the YXmlElement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
|
||||
* position within them.
|
||||
*
|
||||
* Can be created with {@link YXmlFragment#createTreeWalker}
|
||||
*
|
||||
* @public
|
||||
* @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
|
||||
*/
|
||||
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 {any} */ (n.content).type
|
||||
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
|
||||
do {
|
||||
type = /** @type {any} */ (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 = /** @type {AbstractType<any>} */ (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}
|
||||
*/
|
||||
this._prelimContent = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
* * Save this struct in the os
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YXmlFragment()
|
||||
}
|
||||
|
||||
get length () {
|
||||
return this._prelimContent === null ? this._length : this._prelimContent.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subtree of childNodes.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @param {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YXmlFragmentRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlFragment = decoder => new YXmlFragment()
|
||||
84
src/types/YXmlHook.js
Normal file
84
src/types/YXmlHook.js
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
import {
|
||||
YMap,
|
||||
YXmlHookRefID,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
|
||||
} from '../internals.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)
|
||||
*/
|
||||
_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 {AbstractUpdateEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YXmlHookRefID)
|
||||
encoder.writeKey(this.hookName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlHook}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlHook = decoder =>
|
||||
new YXmlHook(decoder.readKey())
|
||||
96
src/types/YXmlText.js
Normal file
96
src/types/YXmlText.js
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
import {
|
||||
YText,
|
||||
YXmlTextRefID,
|
||||
AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* 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 {AbstractUpdateEncoder} encoder
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoder.writeTypeRef(YXmlTextRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder
|
||||
* @return {YXmlText}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlText = decoder => new YXmlText()
|
||||
26
src/utils/AbstractConnector.js
Normal file
26
src/utils/AbstractConnector.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Observable } from 'lib0/observable.js'
|
||||
|
||||
import {
|
||||
Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* This is an abstract interface that all Connectors should implement to keep them interchangeable.
|
||||
*
|
||||
* @note This interface is experimental and it is not advised to actually inherit this class.
|
||||
* It just serves as typing information.
|
||||
*
|
||||
* @extends {Observable<any>}
|
||||
*/
|
||||
export class AbstractConnector extends Observable {
|
||||
/**
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} awareness
|
||||
*/
|
||||
constructor (ydoc, awareness) {
|
||||
super()
|
||||
this.doc = ydoc
|
||||
this.awareness = awareness
|
||||
}
|
||||
}
|
||||
323
src/utils/DeleteSet.js
Normal file
323
src/utils/DeleteSet.js
Normal file
@@ -0,0 +1,323 @@
|
||||
|
||||
import {
|
||||
findIndexSS,
|
||||
getState,
|
||||
splitItem,
|
||||
iterateStructs,
|
||||
AbstractUpdateDecoder, AbstractDSDecoder, AbstractDSEncoder, DSDecoderV2, DSEncoderV2, 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>>}
|
||||
*/
|
||||
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 {number} client
|
||||
* @param {number} clock
|
||||
* @param {number} length
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const addToDeleteSet = (ds, client, clock, length) => {
|
||||
map.setIfUndefined(ds.clients, client, () => []).push(new DeleteItem(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 {AbstractDSEncoder} encoder
|
||||
* @param {DeleteSet} ds
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeDeleteSet = (encoder, ds) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
|
||||
ds.clients.forEach((dsitems, client) => {
|
||||
encoder.resetDsCurVal()
|
||||
encoding.writeVarUint(encoder.restEncoder, client)
|
||||
const len = dsitems.length
|
||||
encoding.writeVarUint(encoder.restEncoder, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = dsitems[i]
|
||||
encoder.writeDsClock(item.clock)
|
||||
encoder.writeDsLen(item.len)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractDSDecoder} decoder
|
||||
* @return {DeleteSet}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readDeleteSet = decoder => {
|
||||
const ds = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
decoder.resetDsCurVal()
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
|
||||
if (numberOfDeletes > 0) {
|
||||
const dsField = map.setIfUndefined(ds.clients, client, () => [])
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array()..
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {AbstractDSDecoder} decoder
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
|
||||
const unappliedDS = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
decoder.resetDsCurVal()
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
|
||||
const structs = store.clients.get(client) || []
|
||||
const state = getState(store, client)
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
const clock = decoder.readDsClock()
|
||||
const clockEnd = clock + decoder.readDsLen()
|
||||
if (clock < state) {
|
||||
if (state < clockEnd) {
|
||||
addToDeleteSet(unappliedDS, client, state, clockEnd - 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 < clockEnd) {
|
||||
if (!struct.deleted) {
|
||||
if (clockEnd < struct.id.clock + struct.length) {
|
||||
structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock))
|
||||
}
|
||||
struct.delete(transaction)
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addToDeleteSet(unappliedDS, client, clock, clockEnd - clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unappliedDS.clients.size > 0) {
|
||||
// TODO: no need for encoding+decoding ds anymore
|
||||
const unappliedDSEncoder = new DSEncoderV2()
|
||||
writeDeleteSet(unappliedDSEncoder, unappliedDS)
|
||||
store.pendingDeleteReaders.push(new DSDecoderV2(decoding.createDecoder((unappliedDSEncoder.toUint8Array()))))
|
||||
}
|
||||
}
|
||||
213
src/utils/Doc.js
Normal file
213
src/utils/Doc.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @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'
|
||||
|
||||
export const generateNewClientId = random.uint32
|
||||
|
||||
/**
|
||||
* 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 = generateNewClientId()
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
*/
|
||||
this.share = new Map()
|
||||
this.store = new StructStore()
|
||||
/**
|
||||
* @type {Transaction | null}
|
||||
*/
|
||||
this._transaction = null
|
||||
/**
|
||||
* @type {Array<Transaction>}
|
||||
*/
|
||||
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) {
|
||||
// @ts-ignore
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the entire document into a js object, recursively traversing each yjs type
|
||||
*
|
||||
* @return {Object<string, any>}
|
||||
*/
|
||||
toJSON () {
|
||||
/**
|
||||
* @type {Object<string, any>}
|
||||
*/
|
||||
const doc = {}
|
||||
|
||||
this.share.forEach((value, key) => {
|
||||
doc[key] = value.toJSON()
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `destroy` event and unregister all event handlers.
|
||||
*/
|
||||
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
82
src/utils/EventHandler.js
Normal 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
90
src/utils/ID.js
Normal 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.entries()) {
|
||||
if (value === type) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
142
src/utils/PermanentUserData.js
Normal file
142
src/utils/PermanentUserData.js
Normal file
@@ -0,0 +1,142 @@
|
||||
|
||||
import {
|
||||
YArray,
|
||||
YMap,
|
||||
readDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSet,
|
||||
DSEncoderV1, DSDecoderV1, ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.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(new DSDecoderV1(decoding.createDecoder(encodedDs)))]))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(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 = new DSEncoderV1()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoder.toUint8Array()])
|
||||
}
|
||||
}
|
||||
}, 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 = new DSEncoderV1()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoder.toUint8Array()])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.entries()) {
|
||||
if (isDeleted(ds, id)) {
|
||||
return userDescription
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
272
src/utils/RelativePosition.js
Normal file
272
src/utils/RelativePosition.js
Normal file
@@ -0,0 +1,272 @@
|
||||
|
||||
import {
|
||||
writeID,
|
||||
readID,
|
||||
compareIDs,
|
||||
getState,
|
||||
findRootTypeKey,
|
||||
Item,
|
||||
createID,
|
||||
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 = createID(type._item.id.client, type._item.id.clock)
|
||||
}
|
||||
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 = /** @type {AbstractType<any>} */ (right.parent)
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
index = right.deleted || !right.countable ? 0 : res.diff
|
||||
let n = right.left
|
||||
while (n !== null) {
|
||||
if (!n.deleted && n.countable) {
|
||||
index += n.length
|
||||
}
|
||||
n = n.left
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
)
|
||||
150
src/utils/Snapshot.js
Normal file
150
src/utils/Snapshot.js
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
import {
|
||||
isDeleted,
|
||||
createDeleteSetFromStructStore,
|
||||
getStateVector,
|
||||
getItemCleanStart,
|
||||
iterateDeletedStructs,
|
||||
writeDeleteSet,
|
||||
writeStateVector,
|
||||
readDeleteSet,
|
||||
readStateVector,
|
||||
createDeleteSet,
|
||||
createID,
|
||||
getState,
|
||||
AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, 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 decoding from 'lib0/decoding.js'
|
||||
import { DefaultDSEncoder } from './encoding.js'
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} sv state map
|
||||
*/
|
||||
constructor (ds, sv) {
|
||||
/**
|
||||
* @type {DeleteSet}
|
||||
*/
|
||||
this.ds = ds
|
||||
/**
|
||||
* State Map
|
||||
* @type {Map<number,number>}
|
||||
*/
|
||||
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.entries()) {
|
||||
if (sv2.get(key) !== value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const [client, dsitems1] of ds1.entries()) {
|
||||
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
|
||||
* @param {AbstractDSEncoder} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
|
||||
writeDeleteSet(encoder, snapshot.ds)
|
||||
writeStateVector(encoder, snapshot.sv)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DefaultDSEncoder())
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @param {AbstractDSDecoder} [decoder]
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(decoding.createDecoder(buf))) => {
|
||||
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new DSDecoderV1(decoding.createDecoder(buf)))
|
||||
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
273
src/utils/StructStore.js
Normal file
273
src/utils/StructStore.js
Normal file
@@ -0,0 +1,273 @@
|
||||
|
||||
import {
|
||||
GC,
|
||||
splitItem,
|
||||
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
export class StructStore {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<GC|Item>>}
|
||||
*/
|
||||
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<GC|Item>}>}
|
||||
*/
|
||||
this.pendingClientsStructRefs = new Map()
|
||||
/**
|
||||
* Stack of pending structs waiting for struct dependencies
|
||||
* Maximum length of stack is structReaders.size
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
this.pendingStack = []
|
||||
/**
|
||||
* @type {Array<DSDecoderV2>}
|
||||
*/
|
||||
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<Item|GC>} structs
|
||||
* @param {number} clock
|
||||
* @return {number}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const findIndexSS = (structs, clock) => {
|
||||
let left = 0
|
||||
let right = structs.length - 1
|
||||
let mid = structs[right]
|
||||
let midclock = mid.id.clock
|
||||
if (midclock === clock) {
|
||||
return right
|
||||
}
|
||||
// @todo does it even make sense to pivot the search?
|
||||
// If a good split misses, it might actually increase the time to find the correct item.
|
||||
// Currently, the only advantage is that search with pivoting might find the item on the first try.
|
||||
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
|
||||
while (left <= right) {
|
||||
mid = structs[midindex]
|
||||
midclock = mid.id.clock
|
||||
if (midclock <= clock) {
|
||||
if (clock < midclock + mid.length) {
|
||||
return midindex
|
||||
}
|
||||
left = midindex + 1
|
||||
} else {
|
||||
right = midindex - 1
|
||||
}
|
||||
midindex = math.floor((left + right) / 2)
|
||||
}
|
||||
// Always check state before looking for a struct in StructStore
|
||||
// Therefore the case of not finding a struct is unexpected
|
||||
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.
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clock
|
||||
*/
|
||||
export const findIndexCleanStart = (transaction, structs, clock) => {
|
||||
const index = findIndexSS(structs, clock)
|
||||
const struct = structs[index]
|
||||
if (struct.id.clock < clock && struct instanceof Item) {
|
||||
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
|
||||
return index + 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {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)
|
||||
}
|
||||
384
src/utils/Transaction.js
Normal file
384
src/utils/Transaction.js
Normal file
@@ -0,0 +1,384 @@
|
||||
|
||||
import {
|
||||
getState,
|
||||
writeStructsFromTransaction,
|
||||
writeDeleteSet,
|
||||
DeleteSet,
|
||||
sortAndMergeDeleteSet,
|
||||
getStateVector,
|
||||
findIndexSS,
|
||||
callEventHandlerListeners,
|
||||
Item,
|
||||
generateNewClientId,
|
||||
createID,
|
||||
AbstractUpdateEncoder, GC, StructStore, UpdateEncoderV2, DefaultUpdateEncoder, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as logging from 'lib0/logging.js'
|
||||
import { callAll } from 'lib0/function.js'
|
||||
|
||||
/**
|
||||
* A transaction is created for every change on the Yjs model. It is possible
|
||||
* 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 {Array<AbstractStruct>}
|
||||
*/
|
||||
this._mergeStructs = []
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
this.origin = origin
|
||||
/**
|
||||
* Stores meta information on the transaction
|
||||
* @type {Map<any,any>}
|
||||
*/
|
||||
this.meta = new Map()
|
||||
/**
|
||||
* Whether this change originates from this doc.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.local = local
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {Transaction} transaction
|
||||
* @return {boolean} Whether data was written.
|
||||
*/
|
||||
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
|
||||
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
|
||||
return false
|
||||
}
|
||||
sortAndMergeDeleteSet(transaction.deleteSet)
|
||||
writeStructsFromTransaction(encoder, transaction)
|
||||
writeDeleteSet(encoder, transaction.deleteSet)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 && /** @type {AbstractType<any>} */ (right.parent)._map.get(right.parentSub) === right) {
|
||||
/** @type {AbstractType<any>} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
const tryGcDeleteSet = (ds, store, gcFilter) => {
|
||||
for (const [client, deleteItems] of ds.clients.entries()) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
const tryMergeDeleteSet = (ds, store) => {
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
ds.clients.forEach((deleteItems, client) => {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
export const tryGc = (ds, store, gcFilter) => {
|
||||
tryGcDeleteSet(ds, store, gcFilter)
|
||||
tryMergeDeleteSet(ds, store)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Transaction>} transactionCleanups
|
||||
* @param {number} i
|
||||
*/
|
||||
const cleanupTransactions = (transactionCleanups, i) => {
|
||||
if (i < transactionCleanups.length) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ds = transaction.deleteSet
|
||||
const mergeStructs = transaction._mergeStructs
|
||||
try {
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
*
|
||||
* Each callback is called even if the other ones throw errors.
|
||||
*
|
||||
* @type {Array<function():void>}
|
||||
*/
|
||||
const fs = []
|
||||
// observe events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) =>
|
||||
fs.push(() => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => {
|
||||
// deep observe events
|
||||
transaction.changedParentTypes.forEach((events, type) =>
|
||||
fs.push(() => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// We don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||
})
|
||||
callAll(fs, [])
|
||||
} finally {
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
tryGcDeleteSet(ds, store, doc.gcFilter)
|
||||
}
|
||||
tryMergeDeleteSet(ds, store)
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
transaction.afterState.forEach((clock, client) => {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (let i = 0; i < mergeStructs.length; i++) {
|
||||
const { client, clock } = mergeStructs[i].id
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
|
||||
doc.clientID = generateNewClientId()
|
||||
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const encoder = new DefaultUpdateEncoder()
|
||||
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||
if (hasContent) {
|
||||
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
if (doc._observers.has('updateV2')) {
|
||||
const encoder = new UpdateEncoderV2()
|
||||
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||
if (hasContent) {
|
||||
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
if (transactionCleanups.length <= i + 1) {
|
||||
doc._transactionCleanups = []
|
||||
doc.emit('afterAllTransactions', [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)
|
||||
if (transactionCleanups.length === 1) {
|
||||
doc.emit('beforeAllTransactions', [doc])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
293
src/utils/UndoManager.js
Normal file
293
src/utils/UndoManager.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
mergeDeleteSets,
|
||||
iterateDeletedStructs,
|
||||
keepItem,
|
||||
transact,
|
||||
createID,
|
||||
redoItem,
|
||||
iterateStructs,
|
||||
isParentOf,
|
||||
followRedone,
|
||||
getItemCleanStart,
|
||||
getState,
|
||||
ID, 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 {Map<number,number>} beforeState
|
||||
* @param {Map<number,number>} afterState
|
||||
*/
|
||||
constructor (ds, beforeState, afterState) {
|
||||
this.ds = ds
|
||||
this.beforeState = beforeState
|
||||
this.afterState = afterState
|
||||
/**
|
||||
* Use this to save and restore metadata like selection range
|
||||
*/
|
||||
this.meta = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UndoManager} undoManager
|
||||
* @param {Array<StackItem>} stack
|
||||
* @param {string} eventType
|
||||
* @return {StackItem?}
|
||||
*/
|
||||
const popStackItem = (undoManager, stack, eventType) => {
|
||||
/**
|
||||
* Whether a change happened
|
||||
* @type {StackItem?}
|
||||
*/
|
||||
let result = null
|
||||
const doc = undoManager.doc
|
||||
const scope = undoManager.scope
|
||||
transact(doc, transaction => {
|
||||
while (stack.length > 0 && result === null) {
|
||||
const store = doc.store
|
||||
const stackItem = /** @type {StackItem} */ (stack.pop())
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsToRedo = new Set()
|
||||
/**
|
||||
* @type {Array<Item>}
|
||||
*/
|
||||
const itemsToDelete = []
|
||||
let performedChange = false
|
||||
stackItem.afterState.forEach((endClock, client) => {
|
||||
const startClock = stackItem.beforeState.get(client) || 0
|
||||
const len = endClock - startClock
|
||||
// @todo iterateStructs should not need the structs parameter
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
if (startClock !== endClock) {
|
||||
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
|
||||
// this must be executed before deleted structs are iterated.
|
||||
getItemCleanStart(transaction, createID(client, startClock))
|
||||
if (endClock < getState(doc.store, client)) {
|
||||
getItemCleanStart(transaction, createID(client, endClock))
|
||||
}
|
||||
iterateStructs(transaction, structs, startClock, len, struct => {
|
||||
if (struct instanceof Item) {
|
||||
if (struct.redone !== null) {
|
||||
let { item, diff } = followRedone(store, struct.id)
|
||||
if (diff > 0) {
|
||||
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
|
||||
}
|
||||
if (item.length > len) {
|
||||
getItemCleanStart(transaction, createID(item.id.client, endClock))
|
||||
}
|
||||
struct = item
|
||||
}
|
||||
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
itemsToDelete.push(struct)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
iterateDeletedStructs(transaction, stackItem.ds, struct => {
|
||||
const id = struct.id
|
||||
const clock = id.clock
|
||||
const client = id.client
|
||||
const startClock = stackItem.beforeState.get(client) || 0
|
||||
const endClock = stackItem.afterState.get(client) || 0
|
||||
if (
|
||||
struct instanceof Item &&
|
||||
scope.some(type => isParentOf(type, struct)) &&
|
||||
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
|
||||
!(clock >= startClock && clock < endClock)
|
||||
) {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
|
||||
})
|
||||
// We want to delete in reverse order so that children are deleted before
|
||||
// parents, so we have more information available when items are filtered.
|
||||
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
|
||||
const item = itemsToDelete[i]
|
||||
if (undoManager.deleteFilter(item)) {
|
||||
item.delete(transaction)
|
||||
performedChange = true
|
||||
}
|
||||
}
|
||||
result = stackItem
|
||||
if (result != null) {
|
||||
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
|
||||
}
|
||||
}
|
||||
}, undoManager)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UndoManagerOptions
|
||||
* @property {number} [UndoManagerOptions.captureTimeout=500]
|
||||
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
|
||||
* it is necessary to filter whan an Undo/Redo operation can delete. If this
|
||||
* filter returns false, the type/item won't be deleted even it is in the
|
||||
* undo/redo scope.
|
||||
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
|
||||
* the redo-stack. You may store additional stack information via the
|
||||
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
|
||||
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||
*
|
||||
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
|
||||
*/
|
||||
export class UndoManager extends Observable {
|
||||
/**
|
||||
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
|
||||
* @param {UndoManagerOptions} options
|
||||
*/
|
||||
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
||||
if (captureTimeout == null) {
|
||||
captureTimeout = 500
|
||||
}
|
||||
super()
|
||||
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
|
||||
this.deleteFilter = deleteFilter
|
||||
trackedOrigins.add(this)
|
||||
this.trackedOrigins = trackedOrigins
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.undoStack = []
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.redoStack = []
|
||||
/**
|
||||
* Whether the client is currently undoing (calling UndoManager.undo)
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.undoing = false
|
||||
this.redoing = false
|
||||
this.doc = /** @type {Doc} */ (this.scope[0].doc)
|
||||
this.lastChange = 0
|
||||
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
// Only track certain transactions
|
||||
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
|
||||
return
|
||||
}
|
||||
const undoing = this.undoing
|
||||
const redoing = this.redoing
|
||||
const stack = undoing ? this.redoStack : this.undoStack
|
||||
if (undoing) {
|
||||
this.stopCapturing() // next undo should not be appended to last stack item
|
||||
} else if (!redoing) {
|
||||
// neither undoing nor redoing: delete redoStack
|
||||
this.redoStack = []
|
||||
}
|
||||
const beforeState = transaction.beforeState
|
||||
const afterState = transaction.afterState
|
||||
const now = time.getUnixTime()
|
||||
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||
// append change to last stack op
|
||||
const lastOp = stack[stack.length - 1]
|
||||
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
|
||||
lastOp.afterState = afterState
|
||||
} else {
|
||||
// create a new stack op
|
||||
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
|
||||
}
|
||||
if (!undoing && !redoing) {
|
||||
this.lastChange = now
|
||||
}
|
||||
// make sure that deleted structs are not gc'd
|
||||
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, true)
|
||||
}
|
||||
})
|
||||
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
392
src/utils/UpdateDecoder.js
Normal file
392
src/utils/UpdateDecoder.js
Normal file
@@ -0,0 +1,392 @@
|
||||
import * as buffer from 'lib0/buffer.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import {
|
||||
ID, createID
|
||||
} from '../internals.js'
|
||||
|
||||
export class AbstractDSDecoder {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.restDecoder = decoder
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
resetDsCurVal () { }
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsClock () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsLen () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
export class AbstractUpdateDecoder extends AbstractDSDecoder {
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
readClient () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} isKey
|
||||
*/
|
||||
readParentInfo () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number} len
|
||||
*/
|
||||
readLen () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation uses JSON parse. We use any-decoding in v2.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
export class DSDecoderV1 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsClock () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV1 extends DSDecoderV1 {
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return decoding.readUint8(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} isKey
|
||||
*/
|
||||
readParentInfo () {
|
||||
return decoding.readVarUint(this.restDecoder) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number} len
|
||||
*/
|
||||
readLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation uses JSON parse. We use any-decoding in v2.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return JSON.parse(decoding.readVarString(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class DSDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.dsCurrVal = 0
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
readDsClock () {
|
||||
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
|
||||
return this.dsCurrVal
|
||||
}
|
||||
|
||||
readDsLen () {
|
||||
const diff = decoding.readVarUint(this.restDecoder) + 1
|
||||
this.dsCurrVal += diff
|
||||
return diff
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV2 extends DSDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
super(decoder)
|
||||
/**
|
||||
* List of cached keys. If the keys[id] does not exist, we read a new key
|
||||
* from stringEncoder and push it to keys.
|
||||
*
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
this.keys = []
|
||||
decoding.readUint8(decoder) // read feature flag - currently unused
|
||||
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return this.clientDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return /** @type {number} */ (this.infoDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return this.stringDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
readParentInfo () {
|
||||
return this.parentInfoDecoder.read() === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return this.typeRefDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
readLen () {
|
||||
return this.lenDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return decoding.readVarUint8Array(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
const keyClock = this.keyClockDecoder.read()
|
||||
if (keyClock < this.keys.length) {
|
||||
return this.keys[keyClock]
|
||||
} else {
|
||||
const key = this.stringDecoder.read()
|
||||
this.keys.push(key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
408
src/utils/UpdateEncoder.js
Normal file
408
src/utils/UpdateEncoder.js
Normal file
@@ -0,0 +1,408 @@
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
import {
|
||||
ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
export class AbstractDSEncoder {
|
||||
constructor () {
|
||||
this.restEncoder = encoding.createEncoder()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
toUint8Array () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the ds value to 0.
|
||||
* The v2 encoder uses this information to reset the initial diff value.
|
||||
*/
|
||||
resetDsCurVal () { }
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeDsClock (clock) { }
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeDsLen (len) { }
|
||||
}
|
||||
|
||||
export class AbstractUpdateEncoder extends AbstractDSEncoder {
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
toUint8Array () {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) { }
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) { }
|
||||
|
||||
/**
|
||||
* Use writeClient and writeClock instead of writeID if possible.
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) { }
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) { }
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) { }
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) { }
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) { }
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) { }
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) { }
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) { }
|
||||
|
||||
/**
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) { }
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) { }
|
||||
}
|
||||
|
||||
export class DSEncoderV1 {
|
||||
constructor () {
|
||||
this.restEncoder = new encoding.Encoder()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeDsClock (clock) {
|
||||
encoding.writeVarUint(this.restEncoder, clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeDsLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV1 extends DSEncoderV1 {
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use writeClient and writeClock instead of writeID if possible.
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
encoding.writeVarUint(this.restEncoder, client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
encoding.writeUint8(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
encoding.writeVarString(this.restEncoder, s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
encoding.writeVarUint(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
encoding.writeVarString(this.restEncoder, key)
|
||||
}
|
||||
}
|
||||
|
||||
export class DSEncoderV2 {
|
||||
constructor () {
|
||||
this.restEncoder = new encoding.Encoder() // encodes all the rest / non-optimized
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeDsClock (clock) {
|
||||
const diff = clock - this.dsCurrVal
|
||||
this.dsCurrVal = clock
|
||||
encoding.writeVarUint(this.restEncoder, diff)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeDsLen (len) {
|
||||
if (len === 0) {
|
||||
error.unexpectedCase()
|
||||
}
|
||||
encoding.writeVarUint(this.restEncoder, len - 1)
|
||||
this.dsCurrVal += len
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV2 extends DSEncoderV2 {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,number>}
|
||||
*/
|
||||
this.keyMap = new Map()
|
||||
/**
|
||||
* Refers to the next uniqe key-identifier to me used.
|
||||
* See writeKey method for more information.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.keyClock = 0
|
||||
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.clientEncoder = new encoding.UintOptRleEncoder()
|
||||
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.stringEncoder = new encoding.StringEncoder()
|
||||
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.typeRefEncoder = new encoding.UintOptRleEncoder()
|
||||
this.lenEncoder = new encoding.UintOptRleEncoder()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeUint8(encoder, 0) // this is a feature flag that we might use in the future
|
||||
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
|
||||
// @note The rest encoder is appended! (note the missing var)
|
||||
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.leftClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.rightClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
this.clientEncoder.write(client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
this.infoEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
this.stringEncoder.write(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
this.parentInfoEncoder.write(isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
this.typeRefEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
this.lenEncoder.write(len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeAny(this.restEncoder, embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
|
||||
* occur very often. For a 3d application, the key `position` might occur very often.
|
||||
*
|
||||
* We cache these keys in a Map and refer to them via a unique number.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
const clock = this.keyMap.get(key)
|
||||
if (clock === undefined) {
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
this.stringEncoder.write(key)
|
||||
} else {
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/utils/YEvent.js
Normal file
226
src/utils/YEvent.js
Normal 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 = /** @type {AbstractType<any>} */ (child._item.parent)._start
|
||||
while (c !== child._item && c !== null) {
|
||||
if (!c.deleted) {
|
||||
i++
|
||||
}
|
||||
c = c.right
|
||||
}
|
||||
path.unshift(i)
|
||||
}
|
||||
child = /** @type {AbstractType<any>} */ (child._item.parent)
|
||||
}
|
||||
return path
|
||||
}
|
||||
611
src/utils/encoding.js
Normal file
611
src/utils/encoding.js
Normal file
@@ -0,0 +1,611 @@
|
||||
|
||||
/**
|
||||
* @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,
|
||||
getState,
|
||||
createID,
|
||||
getStateVector,
|
||||
readAndApplyDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
transact,
|
||||
readItemContent,
|
||||
UpdateDecoderV1,
|
||||
UpdateDecoderV2,
|
||||
UpdateEncoderV1,
|
||||
UpdateEncoderV2,
|
||||
DSDecoderV2,
|
||||
DSEncoderV2,
|
||||
DSDecoderV1,
|
||||
DSEncoderV1,
|
||||
AbstractDSEncoder, AbstractDSDecoder, AbstractUpdateEncoder, AbstractUpdateDecoder, AbstractContent, Doc, Transaction, GC, Item, 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'
|
||||
import * as map from 'lib0/map.js'
|
||||
|
||||
export let DefaultDSEncoder = DSEncoderV1
|
||||
export let DefaultDSDecoder = DSDecoderV1
|
||||
export let DefaultUpdateEncoder = UpdateEncoderV1
|
||||
export let DefaultUpdateDecoder = UpdateDecoderV1
|
||||
|
||||
export const useV1Encoding = () => {
|
||||
DefaultDSEncoder = DSEncoderV1
|
||||
DefaultDSDecoder = DSDecoderV1
|
||||
DefaultUpdateEncoder = UpdateEncoderV1
|
||||
DefaultUpdateDecoder = UpdateDecoderV1
|
||||
}
|
||||
|
||||
export const useV2Encoding = () => {
|
||||
DefaultDSEncoder = DSEncoderV2
|
||||
DefaultDSDecoder = DSDecoderV2
|
||||
DefaultUpdateEncoder = UpdateEncoderV2
|
||||
DefaultUpdateDecoder = UpdateDecoderV2
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} encoder
|
||||
* @param {Array<GC|Item>} 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.restEncoder, structs.length - startNewStructs)
|
||||
encoder.writeClient(client)
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
const firstStruct = structs[startNewStructs]
|
||||
// write first struct with an offset
|
||||
firstStruct.write(encoder, clock - firstStruct.id.clock)
|
||||
for (let i = startNewStructs + 1; i < structs.length; i++) {
|
||||
structs[i].write(encoder, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateEncoder} 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.restEncoder, sm.size)
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
// @ts-ignore
|
||||
writeStructs(encoder, store.clients.get(client), client, clock)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractUpdateDecoder} decoder The decoder object to read data from.
|
||||
* @param {Map<number,Array<GC|Item>>} clientRefs
|
||||
* @param {Doc} doc
|
||||
* @return {Map<number,Array<GC|Item>>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
|
||||
/**
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
const refs = new Array(numberOfStructs)
|
||||
const client = decoder.readClient()
|
||||
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||
// const start = performance.now()
|
||||
clientRefs.set(client, refs)
|
||||
for (let i = 0; i < numberOfStructs; i++) {
|
||||
const info = decoder.readInfo()
|
||||
if ((binary.BITS5 & info) !== 0) {
|
||||
/**
|
||||
* The optimized implementation doesn't use any variables because inlining variables is faster.
|
||||
* Below a non-optimized version is shown that implements the basic algorithm with
|
||||
* a few comments
|
||||
*/
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const struct = new Item(
|
||||
createID(client, clock),
|
||||
null, // leftd
|
||||
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||
null, // right
|
||||
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||
cantCopyParentInfo ? (decoder.readParentInfo() ? doc.get(decoder.readString()) : decoder.readLeftID()) : null, // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
/* A non-optimized implementation of the above algorithm:
|
||||
|
||||
// The item that was originally to the left of this item.
|
||||
const origin = (info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null
|
||||
// The item that was originally to the right of this item.
|
||||
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
const hasParentYKey = cantCopyParentInfo ? decoder.readParentInfo() : false
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const parentYKey = cantCopyParentInfo && hasParentYKey ? decoder.readString() : null
|
||||
|
||||
const struct = new Item(
|
||||
createID(client, clock),
|
||||
null, // leftd
|
||||
origin, // origin
|
||||
null, // right
|
||||
rightOrigin, // right origin
|
||||
cantCopyParentInfo && !hasParentYKey ? decoder.readLeftID() : (parentYKey !== null ? doc.get(parentYKey) : null), // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
*/
|
||||
refs[i] = struct
|
||||
clock += struct.length
|
||||
} else {
|
||||
const len = decoder.readLen()
|
||||
refs[i] = new GC(createID(client, clock), len)
|
||||
clock += len
|
||||
}
|
||||
}
|
||||
// console.log('time to read: ', performance.now() - start) // @todo remove
|
||||
}
|
||||
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 // @todo don't forget to append stackhead at the end
|
||||
const clientsStructRefs = store.pendingClientsStructRefs
|
||||
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
|
||||
const clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
|
||||
if (clientsStructRefsIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const getNextStructTarget = () => {
|
||||
let nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||
while (nextStructsTarget.refs.length === nextStructsTarget.i) {
|
||||
clientsStructRefsIds.pop()
|
||||
if (clientsStructRefsIds.length > 0) {
|
||||
nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||
} else {
|
||||
store.pendingClientsStructRefs.clear()
|
||||
return null
|
||||
}
|
||||
}
|
||||
return nextStructsTarget
|
||||
}
|
||||
let curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null && stack.length === 0) {
|
||||
return
|
||||
}
|
||||
/**
|
||||
* @type {GC|Item}
|
||||
*/
|
||||
let stackHead = stack.length > 0
|
||||
? /** @type {GC|Item} */ (stack.pop())
|
||||
: /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
|
||||
// caching the state because it is used very often
|
||||
const state = new Map()
|
||||
// iterate over all struct readers until we are done
|
||||
while (true) {
|
||||
const localClock = map.setIfUndefined(state, stackHead.id.client, () => getState(store, stackHead.id.client))
|
||||
const offset = stackHead.id.clock < localClock ? localClock - stackHead.id.clock : 0
|
||||
if (stackHead.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
|
||||
/**
|
||||
* @type {{ refs: Array<GC|Item>, i: number }}
|
||||
*/
|
||||
const structRefs = clientsStructRefs.get(stackHead.id.client) || { refs: [], i: 0 }
|
||||
if (structRefs.refs.length !== structRefs.i) {
|
||||
const r = structRefs.refs[structRefs.i]
|
||||
if (r.id.clock < stackHead.id.clock) {
|
||||
// put ref with smaller clock on stack instead and continue
|
||||
structRefs.refs[structRefs.i] = stackHead
|
||||
stackHead = 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
|
||||
stack.push(stackHead)
|
||||
return
|
||||
}
|
||||
const missing = stackHead.getMissing(transaction, store)
|
||||
if (missing === null) {
|
||||
if (offset === 0 || offset < stackHead.length) {
|
||||
stackHead.integrate(transaction, offset)
|
||||
state.set(stackHead.id.client, stackHead.id.clock + stackHead.length)
|
||||
}
|
||||
// iterate to next stackHead
|
||||
if (stack.length > 0) {
|
||||
stackHead = /** @type {GC|Item} */ (stack.pop())
|
||||
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
} else {
|
||||
curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null) {
|
||||
// we are done!
|
||||
break
|
||||
} else {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// get the struct reader that has the missing struct
|
||||
/**
|
||||
* @type {{ refs: Array<GC|Item>, i: number }}
|
||||
*/
|
||||
const structRefs = clientsStructRefs.get(missing) || { refs: [], i: 0 }
|
||||
if (structRefs.refs.length === structRefs.i) {
|
||||
// This update message causally depends on another update message.
|
||||
stack.push(stackHead)
|
||||
return
|
||||
}
|
||||
stack.push(stackHead)
|
||||
stackHead = structRefs.refs[structRefs.i++]
|
||||
}
|
||||
}
|
||||
store.pendingClientsStructRefs.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {AbstractUpdateEncoder} 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<GC|Item>>} clientsStructsRefs
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
const pendingClientsStructRefs = store.pendingClientsStructRefs
|
||||
clientsStructsRefs.forEach((structRefs, client) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<number,{refs:Array<GC|Item>,i:number}>} pendingClientsStructRefs
|
||||
*/
|
||||
const cleanupPendingStructs = pendingClientsStructRefs => {
|
||||
// cleanup pendingClientsStructs if not fully finished
|
||||
pendingClientsStructRefs.forEach((refs, client) => {
|
||||
if (refs.i === refs.refs.length) {
|
||||
pendingClientsStructRefs.delete(client)
|
||||
} else {
|
||||
refs.refs.splice(0, refs.i)
|
||||
refs.i = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {AbstractUpdateDecoder} decoder The decoder object to read data from.
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readStructs = (decoder, transaction, store) => {
|
||||
const clientsStructRefs = new Map()
|
||||
// let start = performance.now()
|
||||
readClientsStructRefs(decoder, clientsStructRefs, transaction.doc)
|
||||
// console.log('time to read structs: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
|
||||
// console.log('time to merge: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
resumeStructIntegration(transaction, store)
|
||||
// console.log('time to integrate: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
cleanupPendingStructs(store.pendingClientsStructRefs)
|
||||
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
tryResumePendingDeleteReaders(transaction, store)
|
||||
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))`
|
||||
* @param {AbstractUpdateDecoder} [structDecoder]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
|
||||
transact(ydoc, transaction => {
|
||||
readStructs(structDecoder, transaction, ydoc.store)
|
||||
readAndApplyDeleteSet(structDecoder, transaction, ydoc.store)
|
||||
}, transactionOrigin, false)
|
||||
|
||||
/**
|
||||
* 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) => readUpdateV2(decoder, ydoc, transactionOrigin, new DefaultUpdateDecoder(decoder))
|
||||
|
||||
/**
|
||||
* 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))`
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
|
||||
const decoder = decoding.createDecoder(update)
|
||||
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => applyUpdateV2(ydoc, update, transactionOrigin, DefaultUpdateDecoder)
|
||||
|
||||
/**
|
||||
* 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 {AbstractUpdateEncoder} 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
|
||||
* @param {AbstractUpdateEncoder} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector, encoder = new UpdateEncoderV2()) => {
|
||||
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
|
||||
writeStateAsUpdate(encoder, doc, targetStateVector)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new DefaultUpdateEncoder())
|
||||
|
||||
/**
|
||||
* Read state vector from Decoder and return as Map
|
||||
*
|
||||
* @param {AbstractDSDecoder} 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.restDecoder)
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const clock = decoding.readVarUint(decoder.restDecoder)
|
||||
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 decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* 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(new DefaultDSDecoder(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* @param {AbstractDSEncoder} encoder
|
||||
* @param {Map<number,number>} sv
|
||||
* @function
|
||||
*/
|
||||
export const writeStateVector = (encoder, sv) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, sv.size)
|
||||
sv.forEach((clock, client) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractDSEncoder} encoder
|
||||
* @param {Doc} doc
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {AbstractDSEncoder} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVectorV2 = (doc, encoder = new DSEncoderV2()) => {
|
||||
writeDocumentStateVector(encoder, doc)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DefaultDSEncoder())
|
||||
22
src/utils/isParentOf.js
Normal file
22
src/utils/isParentOf.js
Normal 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 = /** @type {AbstractType<any>} */ (child.parent)._item
|
||||
}
|
||||
return false
|
||||
}
|
||||
22
src/utils/logging.js
Normal file
22
src/utils/logging.js
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import {
|
||||
AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* Convenient helper to log type information.
|
||||
*
|
||||
* Do not use in productive systems as the output can be immense!
|
||||
*
|
||||
* @param {AbstractType<any>} type
|
||||
*/
|
||||
export const logType = type => {
|
||||
const res = []
|
||||
let n = type._start
|
||||
while (n) {
|
||||
res.push(n)
|
||||
n = n.right
|
||||
}
|
||||
console.log('Children: ', res)
|
||||
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
|
||||
}
|
||||
237
src/y.js
237
src/y.js
@@ -1,237 +0,0 @@
|
||||
/* @flow */
|
||||
'use strict'
|
||||
|
||||
require('./Connector.js')(Y)
|
||||
require('./Database.js')(Y)
|
||||
require('./Transaction.js')(Y)
|
||||
require('./Struct.js')(Y)
|
||||
require('./Utils.js')(Y)
|
||||
require('./Connectors/Test.js')(Y)
|
||||
|
||||
Y.debug = require('debug')
|
||||
|
||||
var requiringModules = {}
|
||||
|
||||
module.exports = Y
|
||||
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}
|
||||
}
|
||||
*/
|
||||
|
||||
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('An options object is expected! ')
|
||||
else if (opts.connector == null) reject('You must specify a connector! (missing connector property)')
|
||||
else if (opts.connector.name == null) reject('You must specify connector name! (missing connector.name property)')
|
||||
else if (opts.db == null) reject('You must specify a database! (missing db property)')
|
||||
else if (opts.connector.name == null) reject('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)
|
||||
setTimeout(function () {
|
||||
Y.requestModules(modules).then(function () {
|
||||
var yconfig = new YConfig(opts)
|
||||
yconfig.db.whenUserIdSet(function () {
|
||||
yconfig.init(function () {
|
||||
resolve(yconfig)
|
||||
})
|
||||
})
|
||||
}).catch(reject)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(function () {
|
||||
this.db.destroyTypes()
|
||||
// make sure to wait for all transactions before destroying the db
|
||||
this.db.requestTransaction(function * () {
|
||||
yield* self.db.destroy()
|
||||
})
|
||||
return this.db.whenTransactionsFinished()
|
||||
})
|
||||
}
|
||||
}
|
||||
9
test.html
Normal file
9
test.html
Normal 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>
|
||||
46
tests/compatibility.tests.js
Normal file
46
tests/compatibility.tests.js
Normal file
File diff suppressed because one or more lines are too long
59
tests/doc.tests.js
Normal file
59
tests/doc.tests.js
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
|
||||
/**
|
||||
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testClientIdDuplicateChange = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.clientID = 0
|
||||
const doc2 = new Y.Doc()
|
||||
doc2.clientID = 0
|
||||
t.assert(doc2.clientID === doc1.clientID)
|
||||
doc1.getArray('a').insert(0, [1, 2])
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.clientID !== doc1.clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetTypeEmptyId = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.getText('').insert(0, 'h')
|
||||
doc1.getText().insert(1, 'i')
|
||||
const doc2 = new Y.Doc()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.getText().toString() === 'hi')
|
||||
t.assert(doc2.getText('').toString() === 'hi')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToJSON = tc => {
|
||||
const doc = new Y.Doc()
|
||||
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
|
||||
|
||||
const arr = doc.getArray('array')
|
||||
arr.push(['test1'])
|
||||
|
||||
const map = doc.getMap('map')
|
||||
map.set('k1', 'v1')
|
||||
const map2 = new Y.Map()
|
||||
map.set('k2', map2)
|
||||
map2.set('m2k1', 'm2v1')
|
||||
|
||||
t.compare(doc.toJSON(), {
|
||||
array: ['test1'],
|
||||
map: {
|
||||
k1: 'v1',
|
||||
k2: {
|
||||
m2k1: 'm2v1'
|
||||
}
|
||||
}
|
||||
}, 'doc.toJSON has array and recursive map')
|
||||
}
|
||||
28
tests/encoding.tests.js
Normal file
28
tests/encoding.tests.js
Normal 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)
|
||||
}
|
||||
25
tests/index.js
Normal file
25
tests/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import * as map from './y-map.tests.js'
|
||||
import * as array from './y-array.tests.js'
|
||||
import * as text from './y-text.tests.js'
|
||||
import * as xml from './y-xml.tests.js'
|
||||
import * as encoding from './encoding.tests.js'
|
||||
import * as undoredo from './undo-redo.tests.js'
|
||||
import * as compatibility from './compatibility.tests.js'
|
||||
import * as doc from './doc.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({
|
||||
doc, map, array, text, xml, encoding, undoredo, compatibility
|
||||
}).then(success => {
|
||||
/* istanbul ignore next */
|
||||
if (isNode) {
|
||||
process.exit(success ? 0 : 1)
|
||||
}
|
||||
})
|
||||
423
tests/testHelper.js
Normal file
423
tests/testHelper.js
Normal file
@@ -0,0 +1,423 @@
|
||||
|
||||
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'
|
||||
import * as Y from '../src/internals.js'
|
||||
export * from '../src/internals.js'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
window.Y = Y // eslint-disable-line
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 Y.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.YArray<any>,array1:Y.YArray<any>,array2:Y.YArray<any>,map0:Y.YMap<any>,map1:Y.YMap<any>,map2:Y.YMap<any>,map3:Y.YMap<any>,text0:Y.YText,text1:Y.YText,text2:Y.YText,xml0:Y.YXmlElement,xml1:Y.YXmlElement,xml2:Y.YXmlElement}}
|
||||
*/
|
||||
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
const result = {
|
||||
users: []
|
||||
}
|
||||
const gen = tc.prng
|
||||
// choose an encoding approach at random
|
||||
if (prng.bool(gen)) {
|
||||
Y.useV2Encoding()
|
||||
} else {
|
||||
Y.useV1Encoding()
|
||||
}
|
||||
|
||||
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.getArray('array')
|
||||
result['map' + i] = y.getMap('map')
|
||||
result['xml' + i] = y.get('xml', Y.YXmlElement)
|
||||
result['text' + i] = y.getText('text')
|
||||
}
|
||||
testConnector.syncAll()
|
||||
result.testObjects = result.users.map(initTestObject || (() => null))
|
||||
Y.useV1Encoding()
|
||||
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.YXmlElement).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(Y.getStateVector(users[i].store), Y.getStateVector(users[i + 1].store))
|
||||
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
|
||||
compareStructStores(users[i].store, users[i + 1].store)
|
||||
}
|
||||
users.map(u => u.destroy())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.Item?} a
|
||||
* @param {Y.Item?} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
|
||||
|
||||
/**
|
||||
* @param {Y.StructStore} ss1
|
||||
* @param {Y.StructStore} ss2
|
||||
*/
|
||||
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 ||
|
||||
// @ts-ignore
|
||||
s1.length !== s2.length
|
||||
) {
|
||||
t.fail('Structs dont match')
|
||||
}
|
||||
if (s1 instanceof Y.Item) {
|
||||
if (
|
||||
!(s2 instanceof Y.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 {Y.DeleteSet} ds1
|
||||
* @param {Y.DeleteSet} ds2
|
||||
*/
|
||||
export const compareDS = (ds1, ds2) => {
|
||||
t.assert(ds1.clients.size === ds2.clients.size)
|
||||
ds1.clients.forEach((deleteItems1, client) => {
|
||||
const deleteItems2 = /** @type {Array<Y.DeleteItem>} */ (ds2.clients.get(client))
|
||||
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.int32(gen, 0, 100) <= 2) {
|
||||
// 2% chance to disconnect/reconnect a random user
|
||||
if (prng.bool(gen)) {
|
||||
testConnector.disconnectRandom()
|
||||
} else {
|
||||
testConnector.reconnectRandom()
|
||||
}
|
||||
} else if (prng.int32(gen, 0, 100) <= 1) {
|
||||
// 1% chance to flush all
|
||||
testConnector.flushAllMessages()
|
||||
} else if (prng.int32(gen, 0, 100) <= 50) {
|
||||
// 50% chance to flush a random message
|
||||
testConnector.flushRandomMessage()
|
||||
}
|
||||
const user = prng.int32(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
248
tests/undo-redo.tests.js
Normal 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)
|
||||
}
|
||||
512
tests/y-array.tests.js
Normal file
512
tests/y-array.tests.js
Normal file
@@ -0,0 +1,512 @@
|
||||
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 testBasicUpdate = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc1.getArray('array').insert(0, ['hi'])
|
||||
const update = Y.encodeStateAsUpdate(doc1)
|
||||
Y.applyUpdate(doc2, update)
|
||||
t.compare(doc2.getArray('array').toArray(), ['hi'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.int32(gen, 1, 4)
|
||||
for (var i = 0; i < len; i++) {
|
||||
content.push(uniqueNumber)
|
||||
}
|
||||
var pos = prng.int32(gen, 0, yarray.length)
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.insert(pos, content)
|
||||
oldContent.splice(pos, 0, ...content)
|
||||
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
|
||||
},
|
||||
function insertTypeArray (user, gen) {
|
||||
const yarray = user.getArray('array')
|
||||
var pos = prng.int32(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.int32(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.int32(gen, 0, length - 1)
|
||||
var delLength = prng.int32(gen, 1, math.min(2, length - somePos))
|
||||
if (prng.bool(gen)) {
|
||||
var type = yarray.get(somePos)
|
||||
if (type.length > 0) {
|
||||
somePos = prng.int32(gen, 0, type.length - 1)
|
||||
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
|
||||
type.delete(somePos, delLength)
|
||||
}
|
||||
} else {
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.delete(somePos, delLength)
|
||||
oldContent.splice(somePos, delLength)
|
||||
t.compareArrays(yarray.toArray(), oldContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests6 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
609
tests/y-map.tests.js
Normal file
609
tests/y-map.tests.js
Normal file
@@ -0,0 +1,609 @@
|
||||
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 testMapHavingIterableAsConstructorParamTests = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
|
||||
const m1 = new Y.Map(Object.entries({ number: 1, string: 'hello' }))
|
||||
map0.set('m1', m1)
|
||||
t.assert(m1.get('number') === 1)
|
||||
t.assert(m1.get('string') === 'hello')
|
||||
|
||||
const m2 = new Y.Map([
|
||||
['object', { x: 1 }],
|
||||
['boolean', true]
|
||||
])
|
||||
map0.set('m2', m2)
|
||||
t.assert(m2.get('object').x === 1)
|
||||
t.assert(m2.get('boolean') === true)
|
||||
|
||||
const m3 = new Y.Map([...m1, ...m2])
|
||||
map0.set('m3', m3)
|
||||
t.assert(m3.get('number') === 1)
|
||||
t.assert(m3.get('string') === 'hello')
|
||||
t.assert(m3.get('object').x === 1)
|
||||
t.assert(m3.get('boolean') === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
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)')
|
||||
t.assert(map0.size === 6, 'client 0 map has correct size')
|
||||
|
||||
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)')
|
||||
t.assert(map1.size === 6, 'client 1 map has correct size')
|
||||
|
||||
// compare disconnected user
|
||||
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
|
||||
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 testSizeAndDeleteOfMapProperty = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
map0.set('stuff', 'c0')
|
||||
map0.set('otherstuff', 'c1')
|
||||
t.assert(map0.size === 2, `map size is ${map0.size} expected 2`)
|
||||
map0.delete('stuff')
|
||||
t.assert(map0.size === 1, `map size after delete is ${map0.size}, expected 1`)
|
||||
map0.delete('otherstuff')
|
||||
t.assert(map0.size === 0, `map size after delete is ${map0.size}, expected 0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
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, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
585
tests/y-text.tests.js
Normal file
585
tests/y-text.tests.js
Normal file
@@ -0,0 +1,585 @@
|
||||
import * as Y from './testHelper.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
const { init, compare } = Y
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' }, { width: 100 })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' }, attributes: { width: 100 } }, { insert: 'b', attributes: { bold: true } }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedNoAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemoved = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.delete(0, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemovedInMidText = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, '1234')
|
||||
text0.insert(2, 'ab', { bold: true })
|
||||
text0.delete(2, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertAndDeleteAtRandomPositions = tc => {
|
||||
const N = 100000
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
const gen = tc.prng
|
||||
|
||||
// create initial content
|
||||
// let expectedResult = init
|
||||
text0.insert(0, prng.word(gen, N / 2, N / 2))
|
||||
|
||||
// apply changes
|
||||
for (let i = 0; i < N; i++) {
|
||||
const pos = prng.uint32(gen, 0, text0.length)
|
||||
if (prng.bool(gen)) {
|
||||
const len = prng.uint32(gen, 1, 5)
|
||||
const word = prng.word(gen, 0, len)
|
||||
text0.insert(pos, word)
|
||||
// expectedResult = expectedResult.slice(0, pos) + word + expectedResult.slice(pos)
|
||||
} else {
|
||||
const len = prng.uint32(gen, 0, math.min(3, text0.length - pos))
|
||||
text0.delete(pos, len)
|
||||
// expectedResult = expectedResult.slice(0, pos) + expectedResult.slice(pos + len)
|
||||
}
|
||||
}
|
||||
// t.compareStrings(text0.toString(), expectedResult)
|
||||
t.describe('final length', '' + text0.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testAppendChars = tc => {
|
||||
const N = 10000
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
|
||||
// apply changes
|
||||
for (let i = 0; i < N; i++) {
|
||||
text0.insert(text0.length, 'a')
|
||||
}
|
||||
t.assert(text0.length === N)
|
||||
}
|
||||
|
||||
const id = Y.createID(0, 0)
|
||||
const c = new Y.ContentString('a')
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBestCase = tc => {
|
||||
const N = 2000000
|
||||
const items = new Array(N)
|
||||
t.measureTime('time to create two million items in the best case', () => {
|
||||
const parent = /** @type {any} */ ({})
|
||||
let prevItem = null
|
||||
for (let i = 0; i < N; i++) {
|
||||
/**
|
||||
* @type {Y.Item}
|
||||
*/
|
||||
const n = new Y.Item(Y.createID(0, 0), null, null, null, null, null, null, c)
|
||||
// items.push(n)
|
||||
items[i] = n
|
||||
n.right = prevItem
|
||||
n.rightOrigin = prevItem ? id : null
|
||||
n.content = c
|
||||
n.parent = parent
|
||||
prevItem = n
|
||||
}
|
||||
})
|
||||
const newArray = new Array(N)
|
||||
t.measureTime('time to copy two million items to new Array', () => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
newArray[i] = items[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tryGc = () => {
|
||||
if (typeof global !== 'undefined' && global.gc) {
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testLargeFragmentedDocument = tc => {
|
||||
const itemsToInsert = 1000000
|
||||
let update = /** @type {any} */ (null)
|
||||
;(() => {
|
||||
const doc1 = new Y.Doc()
|
||||
const text0 = doc1.getText('txt')
|
||||
tryGc()
|
||||
t.measureTime(`time to insert ${itemsToInsert} items`, () => {
|
||||
doc1.transact(() => {
|
||||
for (let i = 0; i < itemsToInsert; i++) {
|
||||
text0.insert(0, '0')
|
||||
}
|
||||
})
|
||||
})
|
||||
tryGc()
|
||||
t.measureTime('time to encode document', () => {
|
||||
update = Y.encodeStateAsUpdateV2(doc1)
|
||||
})
|
||||
t.describe('Document size:', update.byteLength)
|
||||
})()
|
||||
;(() => {
|
||||
const doc2 = new Y.Doc()
|
||||
tryGc()
|
||||
t.measureTime(`time to apply ${itemsToInsert} updates`, () => {
|
||||
Y.applyUpdateV2(doc2, update)
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
// RANDOM TESTS
|
||||
|
||||
let charCounter = 0
|
||||
|
||||
/**
|
||||
* Random tests for pure text operations without formatting.
|
||||
*
|
||||
* @type Array<function(any,prng.PRNG):void>
|
||||
*/
|
||||
const textChanges = [
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert text
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
const prevText = ytext.toString()
|
||||
ytext.insert(insertPos, text)
|
||||
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + text + prevText.slice(insertPos))
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // delete text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
const prevText = ytext.toString()
|
||||
ytext.delete(insertPos, overwrite)
|
||||
t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + prevText.slice(insertPos + overwrite))
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges5 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 5))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges30 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 30))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges40 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 40))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges50 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 50))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges70 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 70))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges90 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 90))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateTextChanges300 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 300))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
const marks = [
|
||||
{ bold: true },
|
||||
{ italic: true },
|
||||
{ italic: true, color: '#888' }
|
||||
]
|
||||
|
||||
const marksChoices = [
|
||||
undefined,
|
||||
...marks
|
||||
]
|
||||
|
||||
/**
|
||||
* Random tests for all features of y-text (formatting, embeds, ..).
|
||||
*
|
||||
* @type Array<function(any,prng.PRNG):void>
|
||||
*/
|
||||
const qChanges = [
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert text
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
const attrs = prng.oneOf(gen, marksChoices)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
ytext.insert(insertPos, text, attrs)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert embed
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.length)
|
||||
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // delete text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
ytext.delete(insertPos, overwrite)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // format text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
const format = prng.oneOf(gen, marks)
|
||||
ytext.format(insertPos, overwrite, format)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert codeblock
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.toString().length)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
const ops = []
|
||||
if (insertPos > 0) {
|
||||
ops.push({ retain: insertPos })
|
||||
}
|
||||
ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } })
|
||||
ytext.applyDelta(ops)
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {any} result
|
||||
*/
|
||||
const checkResult = result => {
|
||||
for (let i = 1; i < result.testObjects.length; i++) {
|
||||
const p1 = result.users[i].getText('text').toDelta()
|
||||
const p2 = result.users[i].getText('text').toDelta()
|
||||
t.compare(p1, p2)
|
||||
}
|
||||
// Uncomment this to find formatting-cleanup issues
|
||||
// const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text'))
|
||||
// t.assert(cleanups === 0)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges1 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2Repeat = tc => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges3 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges30 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 30))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges40 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 40))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges70 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 70))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges100 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 100))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges300 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 300))
|
||||
}
|
||||
75
tests/y-xml.tests.js
Normal file
75
tests/y-xml.tests.js
Normal 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)
|
||||
}
|
||||
64
tsconfig.json
Normal file
64
tsconfig.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"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"]
|
||||
}, /* 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": 0,
|
||||
// "types": ["./src/utils/typedefs.js"]
|
||||
},
|
||||
"include": ["./src/**/*.js", "./tests/**/*.js"]
|
||||
}
|
||||
Reference in New Issue
Block a user