Compare commits
1162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bac783490 | ||
|
|
1508c44f68 | ||
|
|
3dd843372f | ||
|
|
d6be4d9391 | ||
|
|
53f2344017 | ||
|
|
86f7631d1e | ||
|
|
3bb107504f | ||
|
|
4c46ebfb45 | ||
|
|
9d0d63ead7 | ||
|
|
39803c1d11 | ||
|
|
46fae57036 | ||
|
|
e9cb07da55 | ||
|
|
114f28f48e | ||
|
|
a1da486c8a | ||
|
|
4fb9cc2a30 | ||
|
|
e2c9eb7f01 | ||
|
|
6fd33c0720 | ||
|
|
72f3ce75b2 | ||
|
|
fd211731cc | ||
|
|
8049776074 | ||
|
|
32b1338d48 | ||
|
|
c2f0ca3fae | ||
|
|
dfc6b879de | ||
|
|
81f16ff0b5 | ||
|
|
e1a2ccd7f6 | ||
|
|
be8cc8a20c | ||
|
|
a253cfc090 | ||
|
|
992c0b5e32 | ||
|
|
e17d661769 | ||
|
|
fef3fc2a4a | ||
|
|
eee695eeeb | ||
|
|
38e38a92dc | ||
|
|
dadc08597d | ||
|
|
e769a2a354 | ||
|
|
0dd0a4be14 | ||
|
|
7193ae63b7 | ||
|
|
4d48224518 | ||
|
|
b4fc073aa5 | ||
|
|
9c0d1eb209 | ||
|
|
6a9f853d12 | ||
|
|
ce3b0f3043 | ||
|
|
94646b2f45 | ||
|
|
29c2ad4492 | ||
|
|
637fadf38e | ||
|
|
0c6c11d583 | ||
|
|
6f9a2c9df7 | ||
|
|
7876a96163 | ||
|
|
ceba4b1837 | ||
|
|
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 | ||
|
|
dd6c196135 | ||
|
|
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 | ||
|
|
a5506a5ded | ||
|
|
361d4a48e1 | ||
|
|
e23154bec2 | ||
|
|
1682d43c26 | ||
|
|
68c417fe6f | ||
|
|
2ea163a5cf | ||
|
|
020dacdad4 | ||
|
|
42abcc897c | ||
|
|
0a321610aa | ||
|
|
edf47d3491 | ||
|
|
14ee42cad5 | ||
|
|
f990927d3e | ||
|
|
a1cef4662f | ||
|
|
2c343970c4 | ||
|
|
74b41e03e3 | ||
|
|
b242aab955 | ||
|
|
8e4efd9bba | ||
|
|
47d5899058 | ||
|
|
a126a29876 | ||
|
|
4aa720116f | ||
|
|
e29162c3fc | ||
|
|
aa40855953 | ||
|
|
b6545d62fc | ||
|
|
3425d95507 | ||
|
|
53682c17fb | ||
|
|
a492a83f0c | ||
|
|
d340e557c1 | ||
|
|
d5cd9d94d5 | ||
|
|
e1a160b894 | ||
|
|
f996ac83d2 | ||
|
|
922637930f | ||
|
|
ff7e9cdef2 | ||
|
|
f02641deb7 | ||
|
|
f97144356c | ||
|
|
a9fdd5df66 | ||
|
|
e90f241ae0 | ||
|
|
102bef4f92 | ||
|
|
96e9c3c166 | ||
|
|
1080f83990 | ||
|
|
66b6b2a568 | ||
|
|
7415f27fbc | ||
|
|
c9d1f34864 | ||
|
|
34997f940b | ||
|
|
4e9e21e75e | ||
|
|
6c375a37c8 | ||
|
|
cd0cddaf35 | ||
|
|
93c23ddc09 | ||
|
|
480dfdfb77 | ||
|
|
dda2a1ef82 | ||
|
|
f32ff1b613 | ||
|
|
8ab16f4ada | ||
|
|
3fdcf82bcc | ||
|
|
6dd33f4f90 | ||
|
|
0521fac8d8 | ||
|
|
666ab8285c | ||
|
|
675c7f6638 | ||
|
|
463608cb5c | ||
|
|
d1059b5d04 | ||
|
|
8b24284e25 | ||
|
|
08bcdfb008 | ||
|
|
f93d7b1e70 | ||
|
|
4d024883bc | ||
|
|
ecd412c6f6 | ||
|
|
b939cdd086 | ||
|
|
17803266d4 | ||
|
|
f0e88d192c | ||
|
|
e66c0f8a4e | ||
|
|
eba3d590cc | ||
|
|
0b31e63b82 | ||
|
|
d22fbca6cc | ||
|
|
330434ee24 | ||
|
|
2f0216bf89 | ||
|
|
f9d0625bd2 | ||
|
|
7a9d60770a | ||
|
|
059f72ffe1 | ||
|
|
d2d74a64ab | ||
|
|
a1f0140069 | ||
|
|
7bd8e81342 | ||
|
|
34f365cd8f | ||
|
|
b3ba8e7546 | ||
|
|
e1e94bcf5d | ||
|
|
4a83ff8514 | ||
|
|
4078020afd | ||
|
|
e31d5e0e1d | ||
|
|
acbc884eb5 | ||
|
|
f9315288d0 | ||
|
|
3b0d0343f4 | ||
|
|
74c881bb5b | ||
|
|
63f8a891be | ||
|
|
2083cdb6b0 | ||
|
|
2091392031 | ||
|
|
3dc67e075b | ||
|
|
81e72126ce | ||
|
|
e77a753708 | ||
|
|
bc856a09f5 | ||
|
|
f7ae62a906 | ||
|
|
6669be104e | ||
|
|
14d59de2bd | ||
|
|
483d2c78aa | ||
|
|
5b835563c8 | ||
|
|
996566419c | ||
|
|
5d6a9872e2 | ||
|
|
8930865a21 | ||
|
|
2897695680 | ||
|
|
5118f02b49 | ||
|
|
a10933beef | ||
|
|
c2ffe0b697 | ||
|
|
2d1a7b067b | ||
|
|
2675f0277c | ||
|
|
918bc334b2 | ||
|
|
accf0dbafb | ||
|
|
6b8ce0ab4f | ||
|
|
71bf6438e1 | ||
|
|
90b7b01e9a | ||
|
|
895ec86ff6 | ||
|
|
bffd130b92 | ||
|
|
feae0d51bd | ||
|
|
f46c8df605 | ||
|
|
82025c5de9 | ||
|
|
153ec811e2 | ||
|
|
01031d27c3 | ||
|
|
c72f62ecb6 | ||
|
|
e1df1a7a12 | ||
|
|
a7f845f553 | ||
|
|
20321c8a7d | ||
|
|
f3fadd3895 | ||
|
|
08a79d0e7b | ||
|
|
5b21104da3 | ||
|
|
ecc2aef0f8 | ||
|
|
1c32067908 | ||
|
|
fe75ed6208 | ||
|
|
c2404b1e98 | ||
|
|
f363e1e9fc | ||
|
|
749514c074 | ||
|
|
24f8616386 | ||
|
|
d4ee8af772 | ||
|
|
83a42271ad | ||
|
|
88971b4e69 | ||
|
|
f844dcbc1e | ||
|
|
c9c00b5a08 | ||
|
|
d79e3102fc | ||
|
|
ba4f444f32 | ||
|
|
effc2fe576 | ||
|
|
f9a54626b1 | ||
|
|
808a07d218 | ||
|
|
afbe81a602 | ||
|
|
2883947641 | ||
|
|
1c15edd332 | ||
|
|
214380c3ca | ||
|
|
ecbf03ab10 | ||
|
|
5aedddeea3 | ||
|
|
babdb765c5 | ||
|
|
43b4d59f9b | ||
|
|
64a5fae838 | ||
|
|
5036053d9c | ||
|
|
0ec249d388 | ||
|
|
be68a25904 | ||
|
|
fc92b12e85 | ||
|
|
e35f4d19f3 | ||
|
|
6d3c4b21fb | ||
|
|
339590f49e | ||
|
|
429c1f83c1 | ||
|
|
03bab63358 | ||
|
|
06ef22b8ca | ||
|
|
f579a436c7 | ||
|
|
da7e67d97d | ||
|
|
bd54a43a33 | ||
|
|
68c21131d3 | ||
|
|
3826d9b592 | ||
|
|
fa9ff669e4 | ||
|
|
bca7477ca5 | ||
|
|
b40b7e10ab | ||
|
|
d20141fec1 | ||
|
|
5f2a81d064 | ||
|
|
56ba55cbab | ||
|
|
7be262e9f3 | ||
|
|
1da76dbc20 | ||
|
|
8924c3e163 | ||
|
|
608b5e3319 | ||
|
|
d532fc530f | ||
|
|
a5760a45bb | ||
|
|
437955ba84 | ||
|
|
dab72be87f | ||
|
|
89a6ec374e | ||
|
|
4b6352b11a | ||
|
|
31d2a231e3 | ||
|
|
6b1cf18822 | ||
|
|
39dc2317b7 | ||
|
|
38bf398709 | ||
|
|
364ed325b0 | ||
|
|
1b3f5443b3 | ||
|
|
37ac7787d0 | ||
|
|
8e4cf83330 | ||
|
|
5524ab9c20 | ||
|
|
65dc716936 | ||
|
|
5b7a4482cf | ||
|
|
cfa089f7cf | ||
|
|
190442a58d | ||
|
|
0398b5260a | ||
|
|
8544c16771 | ||
|
|
a5f55359c3 | ||
|
|
102555a3b0 | ||
|
|
ece8268e44 | ||
|
|
dd279bccf7 | ||
|
|
7e046e0753 | ||
|
|
51a834d6c9 | ||
|
|
a33d0bf7bc | ||
|
|
fd6a28eb25 | ||
|
|
579fd52455 | ||
|
|
8cfc9d41c3 | ||
|
|
bdf290adb2 | ||
|
|
98d87cb26d | ||
|
|
fbbfa9fd47 | ||
|
|
72bd0d9c3a | ||
|
|
3dbeb2c415 | ||
|
|
2a9fd96958 | ||
|
|
9d34ccfdbc | ||
|
|
7753994e36 | ||
|
|
709779425c | ||
|
|
334db3234b | ||
|
|
0db7fe5d46 | ||
|
|
3a55ca4f21 | ||
|
|
8d14a9cbba | ||
|
|
f6c5051472 | ||
|
|
eff6fb1cc5 | ||
|
|
0ebfae6997 | ||
|
|
e9c40f9a83 | ||
|
|
da2762edf5 | ||
|
|
bd9c3813fd | ||
|
|
940a44bb7c | ||
|
|
aa2e7fd917 | ||
|
|
9fc55f5386 | ||
|
|
8ee563f873 | ||
|
|
5fcfbbfe94 | ||
|
|
8870fdc495 | ||
|
|
58a612eaa1 | ||
|
|
ae12b087e7 | ||
|
|
528dbc6e5a | ||
|
|
1deb453cc5 | ||
|
|
099297ebdf | ||
|
|
3faeb628fd | ||
|
|
d1e30c5040 | ||
|
|
fa45ce04ef | ||
|
|
2d20fd59d0 | ||
|
|
08d07796ee | ||
|
|
010d0d684e | ||
|
|
6dc347642b | ||
|
|
138afe39dc | ||
|
|
0832be2380 | ||
|
|
8a2a184f30 | ||
|
|
4882e77fdd | ||
|
|
78f4f6f5b9 | ||
|
|
317f7f19bb | ||
|
|
00f58ba68f | ||
|
|
029a169114 | ||
|
|
f58889a05d | ||
|
|
e9ac59dcf8 | ||
|
|
57cf20555f | ||
|
|
805ed3b577 | ||
|
|
2a0d5c0cd7 | ||
|
|
13ed66c326 | ||
|
|
1c35198839 | ||
|
|
a7021b9212 | ||
|
|
1fa1f1a668 | ||
|
|
243e62e320 | ||
|
|
15e933ee5b | ||
|
|
605e1052ac | ||
|
|
16c00525d1 | ||
|
|
e9da461625 | ||
|
|
a071c07ee2 | ||
|
|
8dad4f6ed4 | ||
|
|
0980609cc9 | ||
|
|
29f3f3f722 | ||
|
|
04139d3b7e | ||
|
|
45814c4e00 | ||
|
|
cf365b8902 | ||
|
|
aff10fa4db | ||
|
|
181595293f | ||
|
|
ee133ef334 | ||
|
|
661232f23c | ||
|
|
541a93d152 | ||
|
|
d6e1cd42a2 | ||
|
|
51e20fb9c7 | ||
|
|
e32aef4c9f | ||
|
|
9c4074e3e3 | ||
|
|
aadef59934 | ||
|
|
6a13419c62 | ||
|
|
1ace3e3120 | ||
|
|
c95dae3c33 | ||
|
|
82e2254302 | ||
|
|
6e9f990d5c | ||
|
|
7d4adf314d | ||
|
|
8745fd64ca | ||
|
|
638c575dfc | ||
|
|
acf8d37616 | ||
|
|
ae8be1ec6b | ||
|
|
a5f76cee84 | ||
|
|
2013266d56 | ||
|
|
b08aeee4fc | ||
|
|
183f30878e | ||
|
|
5e4c56af29 | ||
|
|
13bef69be4 | ||
|
|
b1d70ef25e | ||
|
|
6f3a291ef5 | ||
|
|
2a601ac6f6 | ||
|
|
82b3e50d49 | ||
|
|
4bfe484fc2 | ||
|
|
b9e21665e2 | ||
|
|
06e7caab2d | ||
|
|
c8ded24842 | ||
|
|
dae0f71cbc | ||
|
|
81c601c65f | ||
|
|
56165a3c10 | ||
|
|
5e0d602e12 | ||
|
|
420821be31 | ||
|
|
d1fda080d9 | ||
|
|
dd5e2adc87 | ||
|
|
ee983ceff6 | ||
|
|
ee116b8ca4 | ||
|
|
d4ef54358b | ||
|
|
ebc628adfc | ||
|
|
4563ccc98e | ||
|
|
a4f7f5c987 | ||
|
|
4a7f09c32d | ||
|
|
f78dc52d7b | ||
|
|
f9f8228db6 | ||
|
|
60b75d1862 | ||
|
|
9b3fe2f197 | ||
|
|
6b153896dd | ||
|
|
66a7d2720d | ||
|
|
d50d34dc12 | ||
|
|
8cc374cabb | ||
|
|
8e9e62b3d0 | ||
|
|
9b45a78e58 | ||
|
|
f862fae473 | ||
|
|
0493d99d57 | ||
|
|
a1026bc365 | ||
|
|
fe4564542b | ||
|
|
7b52111c31 | ||
|
|
c184cb961b | ||
|
|
02f2f6b0fe | ||
|
|
e47dee53a3 | ||
|
|
9b6183ea70 | ||
|
|
79ec71d559 | ||
|
|
bf4d5f24a8 | ||
|
|
9d0373b85b | ||
|
|
f8ad9abcc0 | ||
|
|
b25977be06 | ||
|
|
bffbb6ca27 | ||
|
|
8f63147dbc | ||
|
|
7a274565e5 | ||
|
|
75793d0ced | ||
|
|
7ec409e09f | ||
|
|
fec03dc6e1 | ||
|
|
3142b0f161 | ||
|
|
042bcee482 | ||
|
|
b3e09d001f | ||
|
|
dcec0fe967 | ||
|
|
ae790b6947 | ||
|
|
4b08cbe875 | ||
|
|
01173879a0 | ||
|
|
6f99ee5c34 | ||
|
|
8d1bccbea0 | ||
|
|
b6c278f8e4 | ||
|
|
5a9f59913e | ||
|
|
bf493216a2 | ||
|
|
d37d0ef9af | ||
|
|
c7a6e74dd9 | ||
|
|
24570b791a | ||
|
|
f99853529e | ||
|
|
159f37474d | ||
|
|
1b63f5efde | ||
|
|
c3ba8173d7 | ||
|
|
7a89c1cc6d | ||
|
|
c5b47e88ac | ||
|
|
dc3c6a5d42 | ||
|
|
a9c2ec6ba0 | ||
|
|
f166b9efc5 | ||
|
|
0441b83f74 | ||
|
|
90c82a6a02 | ||
|
|
da25905b73 | ||
|
|
3c07a938cd | ||
|
|
55ccacc442 | ||
|
|
946a11f03d | ||
|
|
93f3a49396 | ||
|
|
3eed100b8d | ||
|
|
eb136ae1bf | ||
|
|
006d0a2643 | ||
|
|
7959bdf5ac | ||
|
|
6f9ee0d9ba | ||
|
|
d901d5f5e4 | ||
|
|
b2c7706a2e | ||
|
|
4d926cf841 | ||
|
|
0314a1b709 | ||
|
|
8a5b69e86c | ||
|
|
ce5250b9d8 | ||
|
|
f51c791490 | ||
|
|
b75305a082 | ||
|
|
8d80fd5614 | ||
|
|
bad6c913fc | ||
|
|
85d85540e7 | ||
|
|
80f1cfd21b | ||
|
|
729d7ed3aa | ||
|
|
0a89150fab | ||
|
|
6fc33e40bb | ||
|
|
7f6592a6b7 | ||
|
|
b9cdbcc6fa | ||
|
|
2a78cdba48 | ||
|
|
b02662c36e | ||
|
|
f44f463e9d | ||
|
|
757bb118ce | ||
|
|
5417ffb999 | ||
|
|
4de979bc33 | ||
|
|
875f56586e | ||
|
|
249b712648 | ||
|
|
58cefae839 | ||
|
|
d9c5ab5fa8 | ||
|
|
6d99ed07f0 | ||
|
|
e55ed9f2b4 | ||
|
|
bb0bfcc5c8 | ||
|
|
b24de43fe2 | ||
|
|
446560d9e8 | ||
|
|
148e46f043 | ||
|
|
e8f20dabd3 | ||
|
|
96ed8b0f98 | ||
|
|
c663230c1b | ||
|
|
0a8118367d | ||
|
|
f932f560bd | ||
|
|
f9542b90db | ||
|
|
014495febd | ||
|
|
82f11c421f | ||
|
|
9059618d1f | ||
|
|
9a8f8fba05 | ||
|
|
3ba89edf7d | ||
|
|
fea6de3bf9 | ||
|
|
2a644f2f0c | ||
|
|
f189ae11b0 | ||
|
|
2e9f8f6d03 | ||
|
|
860934de06 | ||
|
|
792440a71d | ||
|
|
1aacc0e967 | ||
|
|
d4b0c8cbbd | ||
|
|
d6526f12fb | ||
|
|
d3af98cd17 | ||
|
|
e33eb6a928 | ||
|
|
d1be152983 | ||
|
|
548a77833a | ||
|
|
5ba0a7492a | ||
|
|
c65f11b308 | ||
|
|
77b83cae2a | ||
|
|
f609c22be8 | ||
|
|
670854e9d8 | ||
|
|
2bb7ba03cd | ||
|
|
686be484fc | ||
|
|
60de3ce5b0 | ||
|
|
b6fe47efe1 | ||
|
|
e5f16812b3 | ||
|
|
3eb933400a | ||
|
|
58a479be9b | ||
|
|
f835a72151 | ||
|
|
50fa81d191 | ||
|
|
93e75e0111 | ||
|
|
cbcdebf33e | ||
|
|
7c842efd52 | ||
|
|
60daa082f7 | ||
|
|
5681ba84bf |
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
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,6 +1,4 @@
|
|||||||
/node_modules/
|
node_modules
|
||||||
bower_components
|
dist
|
||||||
.directory
|
.vscode
|
||||||
.c9
|
docs
|
||||||
.codio
|
|
||||||
.settings
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
179
INTERNALS.md
Normal file
179
INTERNALS.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Yjs Internals
|
||||||
|
|
||||||
|
This document roughly explains how Yjs works internally. There is a complete
|
||||||
|
walkthrough of the Yjs codebase available as a recording:
|
||||||
|
https://youtu.be/0l5XgnQ6rB4
|
||||||
|
|
||||||
|
The Yjs CRDT algorithm is described in the [YATA
|
||||||
|
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types)
|
||||||
|
from 2016. For an algorithmic view of how it works, the paper is a reasonable
|
||||||
|
place to start. There are a handful of small improvements implemented in Yjs
|
||||||
|
which aren't described in the paper. The most notable is that items have an
|
||||||
|
`originRight` as well as an `origin` property, which improves performance when
|
||||||
|
many concurrent inserts happen after the same character.
|
||||||
|
|
||||||
|
At its heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
|
||||||
|
reuse the CRDT resolution algorithm:
|
||||||
|
|
||||||
|
- Arrays are easy - they're lists of arbitrary items.
|
||||||
|
- Text is a list of characters, optionally punctuated by formatting markers and
|
||||||
|
embeds for rich text support. Several characters can be wrapped in a single
|
||||||
|
linked list `Item` (this is also known as the compound representation of
|
||||||
|
CRDTs). More information about this in [this blog
|
||||||
|
article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/).
|
||||||
|
- Maps are lists of entries. The last inserted entry for each key is used, and
|
||||||
|
all other duplicates for each key are flagged as deleted.
|
||||||
|
|
||||||
|
Each client is assigned a unique *clientID* property on first insert. This is a
|
||||||
|
random 53-bit integer (53 bits because that fits in the javascript safe integer
|
||||||
|
range).
|
||||||
|
|
||||||
|
## List items
|
||||||
|
|
||||||
|
Each item in a Yjs list is made up of two objects:
|
||||||
|
|
||||||
|
- An `Item` (*src/structs/Item.js*). This is used to relate the item to other
|
||||||
|
adjacent items.
|
||||||
|
- An object in the `AbstractType` heirachy (subclasses of
|
||||||
|
*src/types/AbstractType.js* - eg `YText`). This stores the actual content in
|
||||||
|
the Yjs document.
|
||||||
|
|
||||||
|
The item and type object pair have a 1-1 mapping. The item's `content` field
|
||||||
|
references the AbstractType object and the AbstractType object's `_item` field
|
||||||
|
references the item.
|
||||||
|
|
||||||
|
Everything inserted in a Yjs document is given a unique ID, formed from a
|
||||||
|
*ID(clientID, clock)* pair (also known as a [Lamport
|
||||||
|
Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp)). The clock counts
|
||||||
|
up from 0 with the first inserted character or item a client makes. This is
|
||||||
|
similar to automerge's operation IDs, but note that the clock is only
|
||||||
|
incremented by inserts. Deletes are handled in a very different way (see
|
||||||
|
below).
|
||||||
|
|
||||||
|
If a run of characters is inserted into a document (eg `"abc"`), the clock will
|
||||||
|
be incremented for each character (eg 3 times here). But Yjs will only add a
|
||||||
|
single `Item` into the list. This has no effect on the core CRDT algorithm, but
|
||||||
|
the optimization dramatically decreases the number of javascript objects
|
||||||
|
created during normal text editing. This optimization only applies if the
|
||||||
|
characters share the same clientID, they're inserted in order, and all
|
||||||
|
characters have either been deleted or all characters are not deleted. The item
|
||||||
|
will be split if the run is interrupted for any reason (eg a character in the
|
||||||
|
middle of the run is deleted).
|
||||||
|
|
||||||
|
When an item is created, it stores a reference to the IDs of the preceeding and
|
||||||
|
succeeding item. These are stored in the item's `origin` and `originRight`
|
||||||
|
fields, respectively. These are used when peers concurrently insert at the same
|
||||||
|
location in a document. Though quite rare in practice, Yjs needs to make sure
|
||||||
|
the list items always resolve to the same order on all peers. The actual logic
|
||||||
|
is relatively simple - its only a couple dozen lines of code and it lives in
|
||||||
|
the `Item#integrate()` method. The YATA paper has much more detail on the this
|
||||||
|
algorithm.
|
||||||
|
|
||||||
|
### Item Storage
|
||||||
|
|
||||||
|
The items themselves are stored in two data structures and a cache:
|
||||||
|
|
||||||
|
- The items are stored in a tree of doubly-linked lists in *document order*.
|
||||||
|
Each item has `left` and `right` properties linking to its siblings in the
|
||||||
|
document. Items also have a `parent` property to reference their parent in the
|
||||||
|
document tree (null at the root). (And you can access an item's children, if
|
||||||
|
any, through `item.content`).
|
||||||
|
- All items are referenced in *insertion order* inside the struct store
|
||||||
|
(*src/utils/StructStore.js*). This references the list of items inserted by
|
||||||
|
for each client, in chronological order. This is used to find an item in the
|
||||||
|
tree with a given ID (using a binary search). It is also used to efficiently
|
||||||
|
gather the operations a peer is missing during sync (more on this below).
|
||||||
|
|
||||||
|
When a local insert happens, Yjs needs to map the insert position in the
|
||||||
|
document (eg position 1000) to an ID. With just the linked list, this would
|
||||||
|
require a slow O(n) linear scan of the list. But when editing a document, most
|
||||||
|
inserts are either at the same position as the last insert, or nearby. To
|
||||||
|
improve performance, Yjs stores a cache of the 10 most recently looked up
|
||||||
|
insert positions in the document. This is consulted and updated when a position
|
||||||
|
is looked up to improve performance in the average case. The cache is updated
|
||||||
|
using a heuristic that is still changing (currently, it is updated when a new
|
||||||
|
position significantly diverges from existing markers in the cache). Internally
|
||||||
|
this is referred to as the skip list / fast search marker.
|
||||||
|
|
||||||
|
### Deletions
|
||||||
|
|
||||||
|
Deletions in Yjs are treated very differently from insertions. Insertions are
|
||||||
|
implemented as a sequential operation based CRDT, but deletions are treated as
|
||||||
|
a simpler state based CRDT.
|
||||||
|
|
||||||
|
When an item has been deleted by any peer, at any point in history, it is
|
||||||
|
flagged as deleted on the item. (Internally Yjs uses the `info` bitfield.) Yjs
|
||||||
|
does not record metadata about a deletion:
|
||||||
|
|
||||||
|
- No data is kept on *when* an item was deleted, or which user deleted it.
|
||||||
|
- The struct store does not contain deletion records
|
||||||
|
- The clientID's clock is not incremented
|
||||||
|
|
||||||
|
If garbage collection is enabled in Yjs, when an object is deleted its content
|
||||||
|
is discarded. If a deleted object contains children (eg a field is deleted in
|
||||||
|
an object), the content is replaced with a `GC` object (*src/structs/GC.js*).
|
||||||
|
This is a very lightweight structure - it only stores the length of the removed
|
||||||
|
content.
|
||||||
|
|
||||||
|
Yjs has some special logic to share which content in a document has been
|
||||||
|
deleted:
|
||||||
|
|
||||||
|
- When a delete happens, as well as marking the item, the deleted IDs are
|
||||||
|
listed locally within the transaction. (See below for more information about
|
||||||
|
transactions.) When a transaction has been committed locally, the set of
|
||||||
|
deleted items is appended to a transaction's update message.
|
||||||
|
- A snapshot (a marked point in time in the Yjs history) is specified using
|
||||||
|
both the set of (clientID, clock) pairs *and* the set of all deleted item
|
||||||
|
IDs. The deleted set is O(n), but because deletions usually happen in runs,
|
||||||
|
this data set is usually tiny in practice. (The real world editing trace from
|
||||||
|
the B4 benchmark document contains 182k inserts and 77k deleted characters. The
|
||||||
|
deleted set size in a snapshot is only 4.5Kb).
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
|
||||||
|
All updates in Yjs happen within a *transaction*. (Defined in
|
||||||
|
*src/utils/Transaction.js*.)
|
||||||
|
|
||||||
|
The transaction collects a set of updates to the Yjs document to be applied on
|
||||||
|
remote peers atomically. Once a transaction has been committed locally, it
|
||||||
|
generates a compressed *update message* which is broadcast to synchronized
|
||||||
|
remote peers to notify them of the local change. The update message contains:
|
||||||
|
|
||||||
|
- The set of newly inserted items
|
||||||
|
- The set of items deleted within the transaction.
|
||||||
|
|
||||||
|
## Network protocol
|
||||||
|
|
||||||
|
The network protocol is not really a part of Yjs. There are a few relevant
|
||||||
|
concepts that can be used to create a custom network protocol:
|
||||||
|
|
||||||
|
* `update`: The Yjs document can be encoded to an *update* object that can be
|
||||||
|
parsed to reconstruct the document. Also every change on the document fires
|
||||||
|
an incremental document updates that allows clients to sync with each other.
|
||||||
|
The update object is an Uint8Array that efficiently encodes `Item` objects and
|
||||||
|
the delete set.
|
||||||
|
* `state vector`: A state vector defines the know state of each user (a set of
|
||||||
|
tubles `(client, clock)`). This object is also efficiently encoded as a
|
||||||
|
Uint8Array.
|
||||||
|
|
||||||
|
The client can ask a remote client for missing document updates by sending
|
||||||
|
their state vector (often referred to as *sync step 1*). The remote peer can
|
||||||
|
compute the missing `Item` objects using the `clocks` of the respective clients
|
||||||
|
and compute a minimal update message that reflects all missing updates (sync
|
||||||
|
step 2).
|
||||||
|
|
||||||
|
An implementation of the syncing process is in
|
||||||
|
[y-protocols](https://github.com/yjs/y-protocols).
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
A snapshot can be used to restore an old document state. It is a `state vector`
|
||||||
|
+ `delete set`. I client can restore an old document state by iterating through
|
||||||
|
the sequence CRDT and ignoring all Items that have an `id.clock >
|
||||||
|
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
|
||||||
|
use the delete set to find out if an item was deleted or not.
|
||||||
|
|
||||||
|
It is not recommended to restore an old document state using snapshots,
|
||||||
|
although that would certainly be possible. Instead, the old state should be
|
||||||
|
computed by iterating through the newest state and using the additional
|
||||||
|
information from the state vector.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2014 Kevin Jahns <kevin.jahns@rwth-aachen.de>.
|
Copyright (c) 2014
|
||||||
|
- Kevin Jahns <kevin.jahns@rwth-aachen.de>.
|
||||||
|
- Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
33
bower.json
33
bower.json
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "yjs",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"homepage": "https://github.com/DadaMonad/yjs",
|
|
||||||
"authors": [
|
|
||||||
"Kevin Jahns <kevin.jahns@rwth-aachen.de>"
|
|
||||||
],
|
|
||||||
"description": "A Framework that enables Real-Time collaboration on arbitrary data structures.",
|
|
||||||
"main": [
|
|
||||||
"./y.js",
|
|
||||||
"./y-object.html",
|
|
||||||
"./build/node/y.js"
|
|
||||||
],
|
|
||||||
"keywords": [
|
|
||||||
"OT",
|
|
||||||
"collaboration",
|
|
||||||
"synchronization",
|
|
||||||
"ShareJS",
|
|
||||||
"Coweb",
|
|
||||||
"concurrency"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"ignore": [
|
|
||||||
"node_modules",
|
|
||||||
"bower_components",
|
|
||||||
"test",
|
|
||||||
"extras",
|
|
||||||
"test"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"polymer": "Polymer/polymer#~0.5.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
var adaptConnector;
|
|
||||||
|
|
||||||
adaptConnector = function(connector, engine, HB, execution_listener) {
|
|
||||||
var applyHB, encode_state_vector, getHB, getStateVector, parse_state_vector, send_;
|
|
||||||
send_ = function(o) {
|
|
||||||
if (o.uid.creator === HB.getUserId() && (typeof o.uid.op_number !== "string")) {
|
|
||||||
return connector.broadcast(o);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (connector.invokeSync != null) {
|
|
||||||
HB.setInvokeSyncHandler(connector.invokeSync);
|
|
||||||
}
|
|
||||||
execution_listener.push(send_);
|
|
||||||
encode_state_vector = function(v) {
|
|
||||||
var name, value, _results;
|
|
||||||
_results = [];
|
|
||||||
for (name in v) {
|
|
||||||
value = v[name];
|
|
||||||
_results.push({
|
|
||||||
user: name,
|
|
||||||
state: value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
};
|
|
||||||
parse_state_vector = function(v) {
|
|
||||||
var s, state_vector, _i, _len;
|
|
||||||
state_vector = {};
|
|
||||||
for (_i = 0, _len = v.length; _i < _len; _i++) {
|
|
||||||
s = v[_i];
|
|
||||||
state_vector[s.user] = s.state;
|
|
||||||
}
|
|
||||||
return state_vector;
|
|
||||||
};
|
|
||||||
getStateVector = function() {
|
|
||||||
return encode_state_vector(HB.getOperationCounter());
|
|
||||||
};
|
|
||||||
getHB = function(v) {
|
|
||||||
var hb, json, o, state_vector, _i, _len;
|
|
||||||
state_vector = parse_state_vector(v);
|
|
||||||
hb = HB._encode(state_vector);
|
|
||||||
for (_i = 0, _len = hb.length; _i < _len; _i++) {
|
|
||||||
o = hb[_i];
|
|
||||||
o.fromHB = "true";
|
|
||||||
}
|
|
||||||
json = {
|
|
||||||
hb: hb,
|
|
||||||
state_vector: encode_state_vector(HB.getOperationCounter())
|
|
||||||
};
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
applyHB = function(hb) {
|
|
||||||
return engine.applyOp(hb);
|
|
||||||
};
|
|
||||||
connector.getStateVector = getStateVector;
|
|
||||||
connector.getHB = getHB;
|
|
||||||
connector.applyHB = applyHB;
|
|
||||||
connector.whenReceiving(function(sender, op) {
|
|
||||||
if (op.uid.creator !== HB.getUserId()) {
|
|
||||||
return engine.applyOp(op);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (connector._whenBoundToY != null) {
|
|
||||||
return connector._whenBoundToY();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = adaptConnector;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
var Engine;
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && window !== null) {
|
|
||||||
window.unprocessed_counter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && window !== null) {
|
|
||||||
window.unprocessed_exec_counter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && window !== null) {
|
|
||||||
window.unprocessed_types = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Engine = (function() {
|
|
||||||
function Engine(HB, types) {
|
|
||||||
this.HB = HB;
|
|
||||||
this.types = types;
|
|
||||||
this.unprocessed_ops = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Engine.prototype.parseOperation = function(json) {
|
|
||||||
var type;
|
|
||||||
type = this.types[json.type];
|
|
||||||
if ((type != null ? type.parse : void 0) != null) {
|
|
||||||
return type.parse(json);
|
|
||||||
} else {
|
|
||||||
throw new Error("You forgot to specify a parser for type " + json.type + ". The message is " + (JSON.stringify(json)) + ".");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
applyOpsBundle: (ops_json)->
|
|
||||||
ops = []
|
|
||||||
for o in ops_json
|
|
||||||
ops.push @parseOperation o
|
|
||||||
for o in ops
|
|
||||||
if not o.execute()
|
|
||||||
@unprocessed_ops.push o
|
|
||||||
@tryUnprocessed()
|
|
||||||
*/
|
|
||||||
|
|
||||||
Engine.prototype.applyOpsCheckDouble = function(ops_json) {
|
|
||||||
var o, _i, _len, _results;
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = ops_json.length; _i < _len; _i++) {
|
|
||||||
o = ops_json[_i];
|
|
||||||
if (this.HB.getOperation(o.uid) == null) {
|
|
||||||
_results.push(this.applyOp(o));
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
};
|
|
||||||
|
|
||||||
Engine.prototype.applyOps = function(ops_json) {
|
|
||||||
return this.applyOp(ops_json);
|
|
||||||
};
|
|
||||||
|
|
||||||
Engine.prototype.applyOp = function(op_json_array) {
|
|
||||||
var o, op_json, _i, _len;
|
|
||||||
if (op_json_array.constructor !== Array) {
|
|
||||||
op_json_array = [op_json_array];
|
|
||||||
}
|
|
||||||
for (_i = 0, _len = op_json_array.length; _i < _len; _i++) {
|
|
||||||
op_json = op_json_array[_i];
|
|
||||||
o = this.parseOperation(op_json);
|
|
||||||
if (op_json.fromHB != null) {
|
|
||||||
o.fromHB = op_json.fromHB;
|
|
||||||
}
|
|
||||||
if (this.HB.getOperation(o) != null) {
|
|
||||||
|
|
||||||
} else if (((!this.HB.isExpectedOperation(o)) && (o.fromHB == null)) || (!o.execute())) {
|
|
||||||
this.unprocessed_ops.push(o);
|
|
||||||
if (typeof window !== "undefined" && window !== null) {
|
|
||||||
window.unprocessed_types.push(o.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.tryUnprocessed();
|
|
||||||
};
|
|
||||||
|
|
||||||
Engine.prototype.tryUnprocessed = function() {
|
|
||||||
var old_length, op, unprocessed, _i, _len, _ref;
|
|
||||||
while (true) {
|
|
||||||
old_length = this.unprocessed_ops.length;
|
|
||||||
unprocessed = [];
|
|
||||||
_ref = this.unprocessed_ops;
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
op = _ref[_i];
|
|
||||||
if (this.HB.getOperation(op) != null) {
|
|
||||||
|
|
||||||
} else if ((!this.HB.isExpectedOperation(op) && (op.fromHB == null)) || (!op.execute())) {
|
|
||||||
unprocessed.push(op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.unprocessed_ops = unprocessed;
|
|
||||||
if (this.unprocessed_ops.length === old_length) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.unprocessed_ops.length !== 0) {
|
|
||||||
return this.HB.invokeSync();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Engine;
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
||||||
module.exports = Engine;
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
var HistoryBuffer,
|
|
||||||
__bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
|
|
||||||
|
|
||||||
HistoryBuffer = (function() {
|
|
||||||
function HistoryBuffer(user_id) {
|
|
||||||
this.user_id = user_id;
|
|
||||||
this.emptyGarbage = __bind(this.emptyGarbage, this);
|
|
||||||
this.operation_counter = {};
|
|
||||||
this.buffer = {};
|
|
||||||
this.change_listeners = [];
|
|
||||||
this.garbage = [];
|
|
||||||
this.trash = [];
|
|
||||||
this.performGarbageCollection = true;
|
|
||||||
this.garbageCollectTimeout = 30000;
|
|
||||||
this.reserved_identifier_counter = 0;
|
|
||||||
setTimeout(this.emptyGarbage, this.garbageCollectTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.resetUserId = function(id) {
|
|
||||||
var o, o_name, own;
|
|
||||||
own = this.buffer[this.user_id];
|
|
||||||
if (own != null) {
|
|
||||||
for (o_name in own) {
|
|
||||||
o = own[o_name];
|
|
||||||
o.uid.creator = id;
|
|
||||||
}
|
|
||||||
if (this.buffer[id] != null) {
|
|
||||||
throw new Error("You are re-assigning an old user id - this is not (yet) possible!");
|
|
||||||
}
|
|
||||||
this.buffer[id] = own;
|
|
||||||
delete this.buffer[this.user_id];
|
|
||||||
}
|
|
||||||
this.operation_counter[id] = this.operation_counter[this.user_id];
|
|
||||||
delete this.operation_counter[this.user_id];
|
|
||||||
return this.user_id = id;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.emptyGarbage = function() {
|
|
||||||
var o, _i, _len, _ref;
|
|
||||||
_ref = this.garbage;
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
o = _ref[_i];
|
|
||||||
if (typeof o.cleanup === "function") {
|
|
||||||
o.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.garbage = this.trash;
|
|
||||||
this.trash = [];
|
|
||||||
if (this.garbageCollectTimeout !== -1) {
|
|
||||||
this.garbageCollectTimeoutId = setTimeout(this.emptyGarbage, this.garbageCollectTimeout);
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.getUserId = function() {
|
|
||||||
return this.user_id;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.addToGarbageCollector = function() {
|
|
||||||
var o, _i, _len, _results;
|
|
||||||
if (this.performGarbageCollection) {
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = arguments.length; _i < _len; _i++) {
|
|
||||||
o = arguments[_i];
|
|
||||||
if (o != null) {
|
|
||||||
_results.push(this.garbage.push(o));
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.stopGarbageCollection = function() {
|
|
||||||
this.performGarbageCollection = false;
|
|
||||||
this.setManualGarbageCollect();
|
|
||||||
this.garbage = [];
|
|
||||||
return this.trash = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.setManualGarbageCollect = function() {
|
|
||||||
this.garbageCollectTimeout = -1;
|
|
||||||
clearTimeout(this.garbageCollectTimeoutId);
|
|
||||||
return this.garbageCollectTimeoutId = void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.setGarbageCollectTimeout = function(garbageCollectTimeout) {
|
|
||||||
this.garbageCollectTimeout = garbageCollectTimeout;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.getReservedUniqueIdentifier = function() {
|
|
||||||
return {
|
|
||||||
creator: '_',
|
|
||||||
op_number: "_" + (this.reserved_identifier_counter++),
|
|
||||||
doSync: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.getOperationCounter = function(user_id) {
|
|
||||||
var ctn, res, user, _ref;
|
|
||||||
if (user_id == null) {
|
|
||||||
res = {};
|
|
||||||
_ref = this.operation_counter;
|
|
||||||
for (user in _ref) {
|
|
||||||
ctn = _ref[user];
|
|
||||||
res[user] = ctn;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
return this.operation_counter[user_id];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.isExpectedOperation = function(o) {
|
|
||||||
var _base, _name;
|
|
||||||
if ((_base = this.operation_counter)[_name = o.uid.creator] == null) {
|
|
||||||
_base[_name] = 0;
|
|
||||||
}
|
|
||||||
o.uid.op_number <= this.operation_counter[o.uid.creator];
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype._encode = function(state_vector) {
|
|
||||||
var json, o, o_json, o_next, o_number, o_prev, u_name, unknown, user, _ref;
|
|
||||||
if (state_vector == null) {
|
|
||||||
state_vector = {};
|
|
||||||
}
|
|
||||||
json = [];
|
|
||||||
unknown = function(user, o_number) {
|
|
||||||
if ((user == null) || (o_number == null)) {
|
|
||||||
throw new Error("dah!");
|
|
||||||
}
|
|
||||||
return (state_vector[user] == null) || state_vector[user] <= o_number;
|
|
||||||
};
|
|
||||||
_ref = this.buffer;
|
|
||||||
for (u_name in _ref) {
|
|
||||||
user = _ref[u_name];
|
|
||||||
for (o_number in user) {
|
|
||||||
o = user[o_number];
|
|
||||||
if (o.uid.doSync && unknown(u_name, o_number)) {
|
|
||||||
o_json = o._encode();
|
|
||||||
if (o.next_cl != null) {
|
|
||||||
o_next = o.next_cl;
|
|
||||||
while ((o_next.next_cl != null) && unknown(o_next.uid.creator, o_next.uid.op_number)) {
|
|
||||||
o_next = o_next.next_cl;
|
|
||||||
}
|
|
||||||
o_json.next = o_next.getUid();
|
|
||||||
} else if (o.prev_cl != null) {
|
|
||||||
o_prev = o.prev_cl;
|
|
||||||
while ((o_prev.prev_cl != null) && unknown(o_prev.uid.creator, o_prev.uid.op_number)) {
|
|
||||||
o_prev = o_prev.prev_cl;
|
|
||||||
}
|
|
||||||
o_json.prev = o_prev.getUid();
|
|
||||||
}
|
|
||||||
json.push(o_json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.getNextOperationIdentifier = function(user_id) {
|
|
||||||
var uid;
|
|
||||||
if (user_id == null) {
|
|
||||||
user_id = this.user_id;
|
|
||||||
}
|
|
||||||
if (this.operation_counter[user_id] == null) {
|
|
||||||
this.operation_counter[user_id] = 0;
|
|
||||||
}
|
|
||||||
uid = {
|
|
||||||
'creator': user_id,
|
|
||||||
'op_number': this.operation_counter[user_id],
|
|
||||||
'doSync': true
|
|
||||||
};
|
|
||||||
this.operation_counter[user_id]++;
|
|
||||||
return uid;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.getOperation = function(uid) {
|
|
||||||
var o, _ref;
|
|
||||||
if (uid.uid != null) {
|
|
||||||
uid = uid.uid;
|
|
||||||
}
|
|
||||||
o = (_ref = this.buffer[uid.creator]) != null ? _ref[uid.op_number] : void 0;
|
|
||||||
if ((uid.sub != null) && (o != null)) {
|
|
||||||
return o.retrieveSub(uid.sub);
|
|
||||||
} else {
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.addOperation = function(o) {
|
|
||||||
if (this.buffer[o.uid.creator] == null) {
|
|
||||||
this.buffer[o.uid.creator] = {};
|
|
||||||
}
|
|
||||||
if (this.buffer[o.uid.creator][o.uid.op_number] != null) {
|
|
||||||
throw new Error("You must not overwrite operations!");
|
|
||||||
}
|
|
||||||
if ((o.uid.op_number.constructor !== String) && (!this.isExpectedOperation(o)) && (o.fromHB == null)) {
|
|
||||||
throw new Error("this operation was not expected!");
|
|
||||||
}
|
|
||||||
this.addToCounter(o);
|
|
||||||
this.buffer[o.uid.creator][o.uid.op_number] = o;
|
|
||||||
return o;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.removeOperation = function(o) {
|
|
||||||
var _ref;
|
|
||||||
return (_ref = this.buffer[o.uid.creator]) != null ? delete _ref[o.uid.op_number] : void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.setInvokeSyncHandler = function(f) {
|
|
||||||
return this.invokeSync = f;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.invokeSync = function() {};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.renewStateVector = function(state_vector) {
|
|
||||||
var state, user, _results;
|
|
||||||
_results = [];
|
|
||||||
for (user in state_vector) {
|
|
||||||
state = state_vector[user];
|
|
||||||
if ((this.operation_counter[user] == null) || (this.operation_counter[user] < state_vector[user])) {
|
|
||||||
_results.push(this.operation_counter[user] = state_vector[user]);
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
};
|
|
||||||
|
|
||||||
HistoryBuffer.prototype.addToCounter = function(o) {
|
|
||||||
if (this.operation_counter[o.uid.creator] == null) {
|
|
||||||
this.operation_counter[o.uid.creator] = 0;
|
|
||||||
}
|
|
||||||
if (typeof o.uid.op_number === 'number' && o.uid.creator !== this.getUserId()) {
|
|
||||||
if (o.uid.op_number === this.operation_counter[o.uid.creator]) {
|
|
||||||
return this.operation_counter[o.uid.creator]++;
|
|
||||||
} else {
|
|
||||||
return this.invokeSync(o.uid.creator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return HistoryBuffer;
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
||||||
module.exports = HistoryBuffer;
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
var __slice = [].slice,
|
|
||||||
__hasProp = {}.hasOwnProperty,
|
|
||||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
|
||||||
|
|
||||||
module.exports = function(HB) {
|
|
||||||
var execution_listener, types;
|
|
||||||
types = {};
|
|
||||||
execution_listener = [];
|
|
||||||
types.Operation = (function() {
|
|
||||||
function Operation(uid) {
|
|
||||||
this.is_deleted = false;
|
|
||||||
this.garbage_collected = false;
|
|
||||||
this.event_listeners = [];
|
|
||||||
if (uid != null) {
|
|
||||||
this.uid = uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Operation.prototype.type = "Operation";
|
|
||||||
|
|
||||||
Operation.prototype.retrieveSub = function() {
|
|
||||||
throw new Error("sub properties are not enable on this operation type!");
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.observe = function(f) {
|
|
||||||
return this.event_listeners.push(f);
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.unobserve = function(f) {
|
|
||||||
return this.event_listeners = this.event_listeners.filter(function(g) {
|
|
||||||
return f !== g;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.deleteAllObservers = function() {
|
|
||||||
return this.event_listeners = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype["delete"] = function() {
|
|
||||||
(new types.Delete(void 0, this)).execute();
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.callEvent = function() {
|
|
||||||
return this.forwardEvent.apply(this, [this].concat(__slice.call(arguments)));
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.forwardEvent = function() {
|
|
||||||
var args, f, op, _i, _len, _ref, _results;
|
|
||||||
op = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : [];
|
|
||||||
_ref = this.event_listeners;
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
f = _ref[_i];
|
|
||||||
_results.push(f.call.apply(f, [op].concat(__slice.call(args))));
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.isDeleted = function() {
|
|
||||||
return this.is_deleted;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.applyDelete = function(garbagecollect) {
|
|
||||||
if (garbagecollect == null) {
|
|
||||||
garbagecollect = true;
|
|
||||||
}
|
|
||||||
if (!this.garbage_collected) {
|
|
||||||
this.is_deleted = true;
|
|
||||||
if (garbagecollect) {
|
|
||||||
this.garbage_collected = true;
|
|
||||||
return HB.addToGarbageCollector(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.cleanup = function() {
|
|
||||||
HB.removeOperation(this);
|
|
||||||
return this.deleteAllObservers();
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.setParent = function(parent) {
|
|
||||||
this.parent = parent;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.getParent = function() {
|
|
||||||
return this.parent;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.getUid = function() {
|
|
||||||
if (this.uid.noOperation == null) {
|
|
||||||
return this.uid;
|
|
||||||
} else {
|
|
||||||
return this.uid.alt;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.cloneUid = function() {
|
|
||||||
var n, uid, v, _ref;
|
|
||||||
uid = {};
|
|
||||||
_ref = this.getUid();
|
|
||||||
for (n in _ref) {
|
|
||||||
v = _ref[n];
|
|
||||||
uid[n] = v;
|
|
||||||
}
|
|
||||||
return uid;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.dontSync = function() {
|
|
||||||
return this.uid.doSync = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.execute = function() {
|
|
||||||
var l, _i, _len;
|
|
||||||
this.is_executed = true;
|
|
||||||
if (this.uid == null) {
|
|
||||||
this.uid = HB.getNextOperationIdentifier();
|
|
||||||
}
|
|
||||||
if (this.uid.noOperation == null) {
|
|
||||||
HB.addOperation(this);
|
|
||||||
for (_i = 0, _len = execution_listener.length; _i < _len; _i++) {
|
|
||||||
l = execution_listener[_i];
|
|
||||||
l(this._encode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.saveOperation = function(name, op) {
|
|
||||||
if ((op != null ? op.execute : void 0) != null) {
|
|
||||||
return this[name] = op;
|
|
||||||
} else if (op != null) {
|
|
||||||
if (this.unchecked == null) {
|
|
||||||
this.unchecked = {};
|
|
||||||
}
|
|
||||||
return this.unchecked[name] = op;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Operation.prototype.validateSavedOperations = function() {
|
|
||||||
var name, op, op_uid, success, uninstantiated, _ref;
|
|
||||||
uninstantiated = {};
|
|
||||||
success = this;
|
|
||||||
_ref = this.unchecked;
|
|
||||||
for (name in _ref) {
|
|
||||||
op_uid = _ref[name];
|
|
||||||
op = HB.getOperation(op_uid);
|
|
||||||
if (op) {
|
|
||||||
this[name] = op;
|
|
||||||
} else {
|
|
||||||
uninstantiated[name] = op_uid;
|
|
||||||
success = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete this.unchecked;
|
|
||||||
if (!success) {
|
|
||||||
this.unchecked = uninstantiated;
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Operation;
|
|
||||||
|
|
||||||
})();
|
|
||||||
types.Delete = (function(_super) {
|
|
||||||
__extends(Delete, _super);
|
|
||||||
|
|
||||||
function Delete(uid, deletes) {
|
|
||||||
this.saveOperation('deletes', deletes);
|
|
||||||
Delete.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Delete.prototype.type = "Delete";
|
|
||||||
|
|
||||||
Delete.prototype._encode = function() {
|
|
||||||
return {
|
|
||||||
'type': "Delete",
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'deletes': this.deletes.getUid()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Delete.prototype.execute = function() {
|
|
||||||
var res;
|
|
||||||
if (this.validateSavedOperations()) {
|
|
||||||
res = Delete.__super__.execute.apply(this, arguments);
|
|
||||||
if (res) {
|
|
||||||
this.deletes.applyDelete(this);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Delete;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.Delete.parse = function(o) {
|
|
||||||
var deletes_uid, uid;
|
|
||||||
uid = o['uid'], deletes_uid = o['deletes'];
|
|
||||||
return new this(uid, deletes_uid);
|
|
||||||
};
|
|
||||||
types.Insert = (function(_super) {
|
|
||||||
__extends(Insert, _super);
|
|
||||||
|
|
||||||
function Insert(uid, prev_cl, next_cl, origin, parent) {
|
|
||||||
this.saveOperation('parent', parent);
|
|
||||||
this.saveOperation('prev_cl', prev_cl);
|
|
||||||
this.saveOperation('next_cl', next_cl);
|
|
||||||
if (origin != null) {
|
|
||||||
this.saveOperation('origin', origin);
|
|
||||||
} else {
|
|
||||||
this.saveOperation('origin', prev_cl);
|
|
||||||
}
|
|
||||||
Insert.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Insert.prototype.type = "Insert";
|
|
||||||
|
|
||||||
Insert.prototype.applyDelete = function(o) {
|
|
||||||
var callLater, garbagecollect, _ref;
|
|
||||||
if (this.deleted_by == null) {
|
|
||||||
this.deleted_by = [];
|
|
||||||
}
|
|
||||||
callLater = false;
|
|
||||||
if ((this.parent != null) && !this.isDeleted() && (o != null)) {
|
|
||||||
callLater = true;
|
|
||||||
}
|
|
||||||
if (o != null) {
|
|
||||||
this.deleted_by.push(o);
|
|
||||||
}
|
|
||||||
garbagecollect = false;
|
|
||||||
if (this.next_cl.isDeleted()) {
|
|
||||||
garbagecollect = true;
|
|
||||||
}
|
|
||||||
Insert.__super__.applyDelete.call(this, garbagecollect);
|
|
||||||
if (callLater) {
|
|
||||||
this.callOperationSpecificDeleteEvents(o);
|
|
||||||
}
|
|
||||||
if ((_ref = this.prev_cl) != null ? _ref.isDeleted() : void 0) {
|
|
||||||
return this.prev_cl.applyDelete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.cleanup = function() {
|
|
||||||
var d, o, _i, _len, _ref;
|
|
||||||
if (this.next_cl.isDeleted()) {
|
|
||||||
_ref = this.deleted_by;
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
d = _ref[_i];
|
|
||||||
d.cleanup();
|
|
||||||
}
|
|
||||||
o = this.next_cl;
|
|
||||||
while (o.type !== "Delimiter") {
|
|
||||||
if (o.origin === this) {
|
|
||||||
o.origin = this.prev_cl;
|
|
||||||
}
|
|
||||||
o = o.next_cl;
|
|
||||||
}
|
|
||||||
this.prev_cl.next_cl = this.next_cl;
|
|
||||||
this.next_cl.prev_cl = this.prev_cl;
|
|
||||||
return Insert.__super__.cleanup.apply(this, arguments);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.getDistanceToOrigin = function() {
|
|
||||||
var d, o;
|
|
||||||
d = 0;
|
|
||||||
o = this.prev_cl;
|
|
||||||
while (true) {
|
|
||||||
if (this.origin === o) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
d++;
|
|
||||||
o = o.prev_cl;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.execute = function() {
|
|
||||||
var distance_to_origin, i, o;
|
|
||||||
if (!this.validateSavedOperations()) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
if (this.parent != null) {
|
|
||||||
if (this.prev_cl == null) {
|
|
||||||
this.prev_cl = this.parent.beginning;
|
|
||||||
}
|
|
||||||
if (this.origin == null) {
|
|
||||||
this.origin = this.parent.beginning;
|
|
||||||
}
|
|
||||||
if (this.next_cl == null) {
|
|
||||||
this.next_cl = this.parent.end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.prev_cl != null) {
|
|
||||||
distance_to_origin = this.getDistanceToOrigin();
|
|
||||||
o = this.prev_cl.next_cl;
|
|
||||||
i = distance_to_origin;
|
|
||||||
while (true) {
|
|
||||||
if (o !== this.next_cl) {
|
|
||||||
if (o.getDistanceToOrigin() === i) {
|
|
||||||
if (o.uid.creator < this.uid.creator) {
|
|
||||||
this.prev_cl = o;
|
|
||||||
distance_to_origin = i + 1;
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
} else if (o.getDistanceToOrigin() < i) {
|
|
||||||
if (i - distance_to_origin <= o.getDistanceToOrigin()) {
|
|
||||||
this.prev_cl = o;
|
|
||||||
distance_to_origin = i + 1;
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
o = o.next_cl;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.next_cl = this.prev_cl.next_cl;
|
|
||||||
this.prev_cl.next_cl = this;
|
|
||||||
this.next_cl.prev_cl = this;
|
|
||||||
}
|
|
||||||
this.setParent(this.prev_cl.getParent());
|
|
||||||
Insert.__super__.execute.apply(this, arguments);
|
|
||||||
this.callOperationSpecificInsertEvents();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.callOperationSpecificInsertEvents = function() {
|
|
||||||
var _ref;
|
|
||||||
return (_ref = this.parent) != null ? _ref.callEvent([
|
|
||||||
{
|
|
||||||
type: "insert",
|
|
||||||
position: this.getPosition(),
|
|
||||||
object: this.parent,
|
|
||||||
changedBy: this.uid.creator,
|
|
||||||
value: this.content
|
|
||||||
}
|
|
||||||
]) : void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.callOperationSpecificDeleteEvents = function(o) {
|
|
||||||
return this.parent.callEvent([
|
|
||||||
{
|
|
||||||
type: "delete",
|
|
||||||
position: this.getPosition(),
|
|
||||||
object: this.parent,
|
|
||||||
length: 1,
|
|
||||||
changedBy: o.uid.creator
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
Insert.prototype.getPosition = function() {
|
|
||||||
var position, prev;
|
|
||||||
position = 0;
|
|
||||||
prev = this.prev_cl;
|
|
||||||
while (true) {
|
|
||||||
if (prev instanceof types.Delimiter) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!prev.isDeleted()) {
|
|
||||||
position++;
|
|
||||||
}
|
|
||||||
prev = prev.prev_cl;
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Insert;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.ImmutableObject = (function(_super) {
|
|
||||||
__extends(ImmutableObject, _super);
|
|
||||||
|
|
||||||
function ImmutableObject(uid, content) {
|
|
||||||
this.content = content;
|
|
||||||
ImmutableObject.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImmutableObject.prototype.type = "ImmutableObject";
|
|
||||||
|
|
||||||
ImmutableObject.prototype.val = function() {
|
|
||||||
return this.content;
|
|
||||||
};
|
|
||||||
|
|
||||||
ImmutableObject.prototype._encode = function() {
|
|
||||||
var json;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'content': this.content
|
|
||||||
};
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return ImmutableObject;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.ImmutableObject.parse = function(json) {
|
|
||||||
var content, uid;
|
|
||||||
uid = json['uid'], content = json['content'];
|
|
||||||
return new this(uid, content);
|
|
||||||
};
|
|
||||||
types.Delimiter = (function(_super) {
|
|
||||||
__extends(Delimiter, _super);
|
|
||||||
|
|
||||||
function Delimiter(prev_cl, next_cl, origin) {
|
|
||||||
this.saveOperation('prev_cl', prev_cl);
|
|
||||||
this.saveOperation('next_cl', next_cl);
|
|
||||||
this.saveOperation('origin', prev_cl);
|
|
||||||
Delimiter.__super__.constructor.call(this, {
|
|
||||||
noOperation: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Delimiter.prototype.type = "Delimiter";
|
|
||||||
|
|
||||||
Delimiter.prototype.applyDelete = function() {
|
|
||||||
var o;
|
|
||||||
Delimiter.__super__.applyDelete.call(this);
|
|
||||||
o = this.prev_cl;
|
|
||||||
while (o != null) {
|
|
||||||
o.applyDelete();
|
|
||||||
o = o.prev_cl;
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
Delimiter.prototype.cleanup = function() {
|
|
||||||
return Delimiter.__super__.cleanup.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
Delimiter.prototype.execute = function() {
|
|
||||||
var _ref, _ref1;
|
|
||||||
if (((_ref = this.unchecked) != null ? _ref['next_cl'] : void 0) != null) {
|
|
||||||
return Delimiter.__super__.execute.apply(this, arguments);
|
|
||||||
} else if ((_ref1 = this.unchecked) != null ? _ref1['prev_cl'] : void 0) {
|
|
||||||
if (this.validateSavedOperations()) {
|
|
||||||
if (this.prev_cl.next_cl != null) {
|
|
||||||
throw new Error("Probably duplicated operations");
|
|
||||||
}
|
|
||||||
this.prev_cl.next_cl = this;
|
|
||||||
return Delimiter.__super__.execute.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if ((this.prev_cl != null) && (this.prev_cl.next_cl == null)) {
|
|
||||||
delete this.prev_cl.unchecked.next_cl;
|
|
||||||
this.prev_cl.next_cl = this;
|
|
||||||
return Delimiter.__super__.execute.apply(this, arguments);
|
|
||||||
} else if ((this.prev_cl != null) || (this.next_cl != null) || true) {
|
|
||||||
return Delimiter.__super__.execute.apply(this, arguments);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Delimiter.prototype._encode = function() {
|
|
||||||
var _ref, _ref1;
|
|
||||||
return {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'prev': (_ref = this.prev_cl) != null ? _ref.getUid() : void 0,
|
|
||||||
'next': (_ref1 = this.next_cl) != null ? _ref1.getUid() : void 0
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return Delimiter;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.Delimiter.parse = function(json) {
|
|
||||||
var next, prev, uid;
|
|
||||||
uid = json['uid'], prev = json['prev'], next = json['next'];
|
|
||||||
return new this(uid, prev, next);
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
'types': types,
|
|
||||||
'execution_listener': execution_listener
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
var text_types_uninitialized,
|
|
||||||
__hasProp = {}.hasOwnProperty,
|
|
||||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
|
||||||
|
|
||||||
text_types_uninitialized = require("./TextTypes");
|
|
||||||
|
|
||||||
module.exports = function(HB) {
|
|
||||||
var text_types, types;
|
|
||||||
text_types = text_types_uninitialized(HB);
|
|
||||||
types = text_types.types;
|
|
||||||
types.Object = (function(_super) {
|
|
||||||
__extends(Object, _super);
|
|
||||||
|
|
||||||
function Object() {
|
|
||||||
return Object.__super__.constructor.apply(this, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.prototype.type = "Object";
|
|
||||||
|
|
||||||
Object.prototype.applyDelete = function() {
|
|
||||||
return Object.__super__.applyDelete.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.prototype.cleanup = function() {
|
|
||||||
return Object.__super__.cleanup.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.prototype.toJson = function(transform_to_value) {
|
|
||||||
var json, name, o, that, val;
|
|
||||||
if (transform_to_value == null) {
|
|
||||||
transform_to_value = false;
|
|
||||||
}
|
|
||||||
if ((this.bound_json == null) || (Object.observe == null) || true) {
|
|
||||||
val = this.val();
|
|
||||||
json = {};
|
|
||||||
for (name in val) {
|
|
||||||
o = val[name];
|
|
||||||
if (o instanceof types.Object) {
|
|
||||||
json[name] = o.toJson(transform_to_value);
|
|
||||||
} else if (o instanceof types.Array) {
|
|
||||||
json[name] = o.toJson(transform_to_value);
|
|
||||||
} else if (transform_to_value && o instanceof types.Operation) {
|
|
||||||
json[name] = o.val();
|
|
||||||
} else {
|
|
||||||
json[name] = o;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.bound_json = json;
|
|
||||||
if (Object.observe != null) {
|
|
||||||
that = this;
|
|
||||||
Object.observe(this.bound_json, function(events) {
|
|
||||||
var event, _i, _len, _results;
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = events.length; _i < _len; _i++) {
|
|
||||||
event = events[_i];
|
|
||||||
if ((event.changedBy == null) && (event.type === "add" || (event.type = "update"))) {
|
|
||||||
_results.push(that.val(event.name, event.object[event.name]));
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
});
|
|
||||||
this.observe(function(events) {
|
|
||||||
var event, notifier, oldVal, _i, _len, _results;
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = events.length; _i < _len; _i++) {
|
|
||||||
event = events[_i];
|
|
||||||
if (event.created_ !== HB.getUserId()) {
|
|
||||||
notifier = Object.getNotifier(that.bound_json);
|
|
||||||
oldVal = that.bound_json[event.name];
|
|
||||||
if (oldVal != null) {
|
|
||||||
notifier.performChange('update', function() {
|
|
||||||
return that.bound_json[event.name] = that.val(event.name);
|
|
||||||
}, that.bound_json);
|
|
||||||
_results.push(notifier.notify({
|
|
||||||
object: that.bound_json,
|
|
||||||
type: 'update',
|
|
||||||
name: event.name,
|
|
||||||
oldValue: oldVal,
|
|
||||||
changedBy: event.changedBy
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
notifier.performChange('add', function() {
|
|
||||||
return that.bound_json[event.name] = that.val(event.name);
|
|
||||||
}, that.bound_json);
|
|
||||||
_results.push(notifier.notify({
|
|
||||||
object: that.bound_json,
|
|
||||||
type: 'add',
|
|
||||||
name: event.name,
|
|
||||||
oldValue: oldVal,
|
|
||||||
changedBy: event.changedBy
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.bound_json;
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.prototype.val = function(name, content) {
|
|
||||||
var args, i, o, type, _i, _ref;
|
|
||||||
if ((name != null) && arguments.length > 1) {
|
|
||||||
if ((content != null) && (content.constructor != null)) {
|
|
||||||
type = types[content.constructor.name];
|
|
||||||
if ((type != null) && (type.create != null)) {
|
|
||||||
args = [];
|
|
||||||
for (i = _i = 1, _ref = arguments.length; 1 <= _ref ? _i < _ref : _i > _ref; i = 1 <= _ref ? ++_i : --_i) {
|
|
||||||
args.push(arguments[i]);
|
|
||||||
}
|
|
||||||
o = type.create.apply(null, args);
|
|
||||||
return Object.__super__.val.call(this, name, o);
|
|
||||||
} else {
|
|
||||||
throw new Error("The " + content.constructor.name + "-type is not (yet) supported in Y.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Object.__super__.val.call(this, name, content);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Object.__super__.val.call(this, name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.prototype._encode = function() {
|
|
||||||
return {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return Object;
|
|
||||||
|
|
||||||
})(types.MapManager);
|
|
||||||
types.Object.parse = function(json) {
|
|
||||||
var uid;
|
|
||||||
uid = json['uid'];
|
|
||||||
return new this(uid);
|
|
||||||
};
|
|
||||||
types.Object.create = function(content, mutable) {
|
|
||||||
var json, n, o;
|
|
||||||
json = new types.Object().execute();
|
|
||||||
for (n in content) {
|
|
||||||
o = content[n];
|
|
||||||
json.val(n, o, mutable);
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
types.Number = {};
|
|
||||||
types.Number.create = function(content) {
|
|
||||||
return content;
|
|
||||||
};
|
|
||||||
return text_types;
|
|
||||||
};
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
var basic_types_uninitialized,
|
|
||||||
__hasProp = {}.hasOwnProperty,
|
|
||||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
|
||||||
|
|
||||||
basic_types_uninitialized = require("./BasicTypes");
|
|
||||||
|
|
||||||
module.exports = function(HB) {
|
|
||||||
var basic_types, types;
|
|
||||||
basic_types = basic_types_uninitialized(HB);
|
|
||||||
types = basic_types.types;
|
|
||||||
types.MapManager = (function(_super) {
|
|
||||||
__extends(MapManager, _super);
|
|
||||||
|
|
||||||
function MapManager(uid) {
|
|
||||||
this.map = {};
|
|
||||||
MapManager.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
MapManager.prototype.type = "MapManager";
|
|
||||||
|
|
||||||
MapManager.prototype.applyDelete = function() {
|
|
||||||
var name, p, _ref;
|
|
||||||
_ref = this.map;
|
|
||||||
for (name in _ref) {
|
|
||||||
p = _ref[name];
|
|
||||||
p.applyDelete();
|
|
||||||
}
|
|
||||||
return MapManager.__super__.applyDelete.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
MapManager.prototype.cleanup = function() {
|
|
||||||
return MapManager.__super__.cleanup.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
MapManager.prototype.val = function(name, content) {
|
|
||||||
var o, prop, result, _ref;
|
|
||||||
if (arguments.length > 1) {
|
|
||||||
this.retrieveSub(name).replace(content);
|
|
||||||
return this;
|
|
||||||
} else if (name != null) {
|
|
||||||
prop = this.map[name];
|
|
||||||
if ((prop != null) && !prop.isContentDeleted()) {
|
|
||||||
return prop.val();
|
|
||||||
} else {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = {};
|
|
||||||
_ref = this.map;
|
|
||||||
for (name in _ref) {
|
|
||||||
o = _ref[name];
|
|
||||||
if (!o.isContentDeleted()) {
|
|
||||||
result[name] = o.val();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
MapManager.prototype["delete"] = function(name) {
|
|
||||||
var _ref;
|
|
||||||
if ((_ref = this.map[name]) != null) {
|
|
||||||
_ref.deleteContent();
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
MapManager.prototype.retrieveSub = function(property_name) {
|
|
||||||
var event_properties, event_this, map_uid, rm, rm_uid;
|
|
||||||
if (this.map[property_name] == null) {
|
|
||||||
event_properties = {
|
|
||||||
name: property_name
|
|
||||||
};
|
|
||||||
event_this = this;
|
|
||||||
map_uid = this.cloneUid();
|
|
||||||
map_uid.sub = property_name;
|
|
||||||
rm_uid = {
|
|
||||||
noOperation: true,
|
|
||||||
alt: map_uid
|
|
||||||
};
|
|
||||||
rm = new types.ReplaceManager(event_properties, event_this, rm_uid);
|
|
||||||
this.map[property_name] = rm;
|
|
||||||
rm.setParent(this, property_name);
|
|
||||||
rm.execute();
|
|
||||||
}
|
|
||||||
return this.map[property_name];
|
|
||||||
};
|
|
||||||
|
|
||||||
return MapManager;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.ListManager = (function(_super) {
|
|
||||||
__extends(ListManager, _super);
|
|
||||||
|
|
||||||
function ListManager(uid) {
|
|
||||||
this.beginning = new types.Delimiter(void 0, void 0);
|
|
||||||
this.end = new types.Delimiter(this.beginning, void 0);
|
|
||||||
this.beginning.next_cl = this.end;
|
|
||||||
this.beginning.execute();
|
|
||||||
this.end.execute();
|
|
||||||
ListManager.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
ListManager.prototype.type = "ListManager";
|
|
||||||
|
|
||||||
ListManager.prototype.execute = function() {
|
|
||||||
if (this.validateSavedOperations()) {
|
|
||||||
this.beginning.setParent(this);
|
|
||||||
this.end.setParent(this);
|
|
||||||
return ListManager.__super__.execute.apply(this, arguments);
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ListManager.prototype.getLastOperation = function() {
|
|
||||||
return this.end.prev_cl;
|
|
||||||
};
|
|
||||||
|
|
||||||
ListManager.prototype.getFirstOperation = function() {
|
|
||||||
return this.beginning.next_cl;
|
|
||||||
};
|
|
||||||
|
|
||||||
ListManager.prototype.toArray = function() {
|
|
||||||
var o, result;
|
|
||||||
o = this.beginning.next_cl;
|
|
||||||
result = [];
|
|
||||||
while (o !== this.end) {
|
|
||||||
result.push(o);
|
|
||||||
o = o.next_cl;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
ListManager.prototype.getOperationByPosition = function(position) {
|
|
||||||
var o;
|
|
||||||
o = this.beginning;
|
|
||||||
while (true) {
|
|
||||||
if (o instanceof types.Delimiter && (o.prev_cl != null)) {
|
|
||||||
o = o.prev_cl;
|
|
||||||
while (o.isDeleted() || !(o instanceof types.Delimiter)) {
|
|
||||||
o = o.prev_cl;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (position <= 0 && !o.isDeleted()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
o = o.next_cl;
|
|
||||||
if (!o.isDeleted()) {
|
|
||||||
position -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
};
|
|
||||||
|
|
||||||
return ListManager;
|
|
||||||
|
|
||||||
})(types.Operation);
|
|
||||||
types.ReplaceManager = (function(_super) {
|
|
||||||
__extends(ReplaceManager, _super);
|
|
||||||
|
|
||||||
function ReplaceManager(event_properties, event_this, uid, beginning, end) {
|
|
||||||
this.event_properties = event_properties;
|
|
||||||
this.event_this = event_this;
|
|
||||||
if (this.event_properties['object'] == null) {
|
|
||||||
this.event_properties['object'] = this.event_this;
|
|
||||||
}
|
|
||||||
ReplaceManager.__super__.constructor.call(this, uid, beginning, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReplaceManager.prototype.type = "ReplaceManager";
|
|
||||||
|
|
||||||
ReplaceManager.prototype.applyDelete = function() {
|
|
||||||
var o;
|
|
||||||
o = this.beginning;
|
|
||||||
while (o != null) {
|
|
||||||
o.applyDelete();
|
|
||||||
o = o.next_cl;
|
|
||||||
}
|
|
||||||
return ReplaceManager.__super__.applyDelete.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.cleanup = function() {
|
|
||||||
return ReplaceManager.__super__.cleanup.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.callEventDecorator = function(events) {
|
|
||||||
var event, name, prop, _i, _len, _ref;
|
|
||||||
if (!this.isDeleted()) {
|
|
||||||
for (_i = 0, _len = events.length; _i < _len; _i++) {
|
|
||||||
event = events[_i];
|
|
||||||
_ref = this.event_properties;
|
|
||||||
for (name in _ref) {
|
|
||||||
prop = _ref[name];
|
|
||||||
event[name] = prop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.event_this.callEvent(events);
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.replace = function(content, replaceable_uid) {
|
|
||||||
var o, relp;
|
|
||||||
o = this.getLastOperation();
|
|
||||||
relp = (new types.Replaceable(content, this, replaceable_uid, o, o.next_cl)).execute();
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.isContentDeleted = function() {
|
|
||||||
return this.getLastOperation().isDeleted();
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.deleteContent = function() {
|
|
||||||
(new types.Delete(void 0, this.getLastOperation().uid)).execute();
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype.val = function() {
|
|
||||||
var o;
|
|
||||||
o = this.getLastOperation();
|
|
||||||
return typeof o.val === "function" ? o.val() : void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ReplaceManager.prototype._encode = function() {
|
|
||||||
var json;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'beginning': this.beginning.getUid(),
|
|
||||||
'end': this.end.getUid()
|
|
||||||
};
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return ReplaceManager;
|
|
||||||
|
|
||||||
})(types.ListManager);
|
|
||||||
types.Replaceable = (function(_super) {
|
|
||||||
__extends(Replaceable, _super);
|
|
||||||
|
|
||||||
function Replaceable(content, parent, uid, prev, next, origin, is_deleted) {
|
|
||||||
if ((content != null) && (content.creator != null)) {
|
|
||||||
this.saveOperation('content', content);
|
|
||||||
} else {
|
|
||||||
this.content = content;
|
|
||||||
}
|
|
||||||
this.saveOperation('parent', parent);
|
|
||||||
Replaceable.__super__.constructor.call(this, uid, prev, next, origin);
|
|
||||||
this.is_deleted = is_deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
Replaceable.prototype.type = "Replaceable";
|
|
||||||
|
|
||||||
Replaceable.prototype.val = function() {
|
|
||||||
return this.content;
|
|
||||||
};
|
|
||||||
|
|
||||||
Replaceable.prototype.applyDelete = function() {
|
|
||||||
var res, _base, _base1, _base2;
|
|
||||||
res = Replaceable.__super__.applyDelete.apply(this, arguments);
|
|
||||||
if (this.content != null) {
|
|
||||||
if (this.next_cl.type !== "Delimiter") {
|
|
||||||
if (typeof (_base = this.content).deleteAllObservers === "function") {
|
|
||||||
_base.deleteAllObservers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof (_base1 = this.content).applyDelete === "function") {
|
|
||||||
_base1.applyDelete();
|
|
||||||
}
|
|
||||||
if (typeof (_base2 = this.content).dontSync === "function") {
|
|
||||||
_base2.dontSync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.content = null;
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
Replaceable.prototype.cleanup = function() {
|
|
||||||
return Replaceable.__super__.cleanup.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
Replaceable.prototype.callOperationSpecificInsertEvents = function() {
|
|
||||||
var old_value;
|
|
||||||
if (this.next_cl.type === "Delimiter" && this.prev_cl.type !== "Delimiter") {
|
|
||||||
if (!this.is_deleted) {
|
|
||||||
old_value = this.prev_cl.content;
|
|
||||||
this.parent.callEventDecorator([
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
changedBy: this.uid.creator,
|
|
||||||
oldValue: old_value
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.prev_cl.applyDelete();
|
|
||||||
} else if (this.next_cl.type !== "Delimiter") {
|
|
||||||
this.applyDelete();
|
|
||||||
} else {
|
|
||||||
this.parent.callEventDecorator([
|
|
||||||
{
|
|
||||||
type: "add",
|
|
||||||
changedBy: this.uid.creator
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
Replaceable.prototype.callOperationSpecificDeleteEvents = function(o) {
|
|
||||||
if (this.next_cl.type === "Delimiter") {
|
|
||||||
return this.parent.callEventDecorator([
|
|
||||||
{
|
|
||||||
type: "delete",
|
|
||||||
changedBy: o.uid.creator,
|
|
||||||
oldValue: this.content
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Replaceable.prototype._encode = function() {
|
|
||||||
var json;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'parent': this.parent.getUid(),
|
|
||||||
'prev': this.prev_cl.getUid(),
|
|
||||||
'next': this.next_cl.getUid(),
|
|
||||||
'origin': this.origin.getUid(),
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'is_deleted': this.is_deleted
|
|
||||||
};
|
|
||||||
if (this.content instanceof types.Operation) {
|
|
||||||
json['content'] = this.content.getUid();
|
|
||||||
} else {
|
|
||||||
if ((this.content != null) && (this.content.creator != null)) {
|
|
||||||
throw new Error("You must not set creator here!");
|
|
||||||
}
|
|
||||||
json['content'] = this.content;
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Replaceable;
|
|
||||||
|
|
||||||
})(types.Insert);
|
|
||||||
types.Replaceable.parse = function(json) {
|
|
||||||
var content, is_deleted, next, origin, parent, prev, uid;
|
|
||||||
content = json['content'], parent = json['parent'], uid = json['uid'], prev = json['prev'], next = json['next'], origin = json['origin'], is_deleted = json['is_deleted'];
|
|
||||||
return new this(content, parent, uid, prev, next, origin, is_deleted);
|
|
||||||
};
|
|
||||||
return basic_types;
|
|
||||||
};
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
var structured_types_uninitialized,
|
|
||||||
__hasProp = {}.hasOwnProperty,
|
|
||||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
|
||||||
|
|
||||||
structured_types_uninitialized = require("./StructuredTypes");
|
|
||||||
|
|
||||||
module.exports = function(HB) {
|
|
||||||
var parser, structured_types, types;
|
|
||||||
structured_types = structured_types_uninitialized(HB);
|
|
||||||
types = structured_types.types;
|
|
||||||
parser = structured_types.parser;
|
|
||||||
types.TextInsert = (function(_super) {
|
|
||||||
__extends(TextInsert, _super);
|
|
||||||
|
|
||||||
function TextInsert(content, uid, prev, next, origin, parent) {
|
|
||||||
if (content != null ? content.creator : void 0) {
|
|
||||||
this.saveOperation('content', content);
|
|
||||||
} else {
|
|
||||||
this.content = content;
|
|
||||||
}
|
|
||||||
TextInsert.__super__.constructor.call(this, uid, prev, next, origin, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextInsert.prototype.type = "TextInsert";
|
|
||||||
|
|
||||||
TextInsert.prototype.getLength = function() {
|
|
||||||
if (this.isDeleted()) {
|
|
||||||
return 0;
|
|
||||||
} else {
|
|
||||||
return this.content.length;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
TextInsert.prototype.applyDelete = function() {
|
|
||||||
TextInsert.__super__.applyDelete.apply(this, arguments);
|
|
||||||
if (this.content instanceof types.Operation) {
|
|
||||||
this.content.applyDelete();
|
|
||||||
}
|
|
||||||
return this.content = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
TextInsert.prototype.execute = function() {
|
|
||||||
if (!this.validateSavedOperations()) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
if (this.content instanceof types.Operation) {
|
|
||||||
this.content.insert_parent = this;
|
|
||||||
}
|
|
||||||
return TextInsert.__super__.execute.call(this);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
TextInsert.prototype.val = function(current_position) {
|
|
||||||
if (this.isDeleted() || (this.content == null)) {
|
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return this.content;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
TextInsert.prototype._encode = function() {
|
|
||||||
var json, _ref;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid(),
|
|
||||||
'prev': this.prev_cl.getUid(),
|
|
||||||
'next': this.next_cl.getUid(),
|
|
||||||
'origin': this.origin.getUid(),
|
|
||||||
'parent': this.parent.getUid()
|
|
||||||
};
|
|
||||||
if (((_ref = this.content) != null ? _ref.getUid : void 0) != null) {
|
|
||||||
json['content'] = this.content.getUid();
|
|
||||||
} else {
|
|
||||||
json['content'] = this.content;
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return TextInsert;
|
|
||||||
|
|
||||||
})(types.Insert);
|
|
||||||
types.TextInsert.parse = function(json) {
|
|
||||||
var content, next, origin, parent, prev, uid;
|
|
||||||
content = json['content'], uid = json['uid'], prev = json['prev'], next = json['next'], origin = json['origin'], parent = json['parent'];
|
|
||||||
return new types.TextInsert(content, uid, prev, next, origin, parent);
|
|
||||||
};
|
|
||||||
types.Array = (function(_super) {
|
|
||||||
__extends(Array, _super);
|
|
||||||
|
|
||||||
function Array() {
|
|
||||||
return Array.__super__.constructor.apply(this, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.type = "Array";
|
|
||||||
|
|
||||||
Array.prototype.applyDelete = function() {
|
|
||||||
var o;
|
|
||||||
o = this.end;
|
|
||||||
while (o != null) {
|
|
||||||
o.applyDelete();
|
|
||||||
o = o.prev_cl;
|
|
||||||
}
|
|
||||||
return Array.__super__.applyDelete.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.cleanup = function() {
|
|
||||||
return Array.__super__.cleanup.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.toJson = function(transform_to_value) {
|
|
||||||
var i, o, val, _i, _len, _results;
|
|
||||||
if (transform_to_value == null) {
|
|
||||||
transform_to_value = false;
|
|
||||||
}
|
|
||||||
val = this.val();
|
|
||||||
_results = [];
|
|
||||||
for (o = _i = 0, _len = val.length; _i < _len; o = ++_i) {
|
|
||||||
i = val[o];
|
|
||||||
if (o instanceof types.Object) {
|
|
||||||
_results.push(o.toJson(transform_to_value));
|
|
||||||
} else if (o instanceof types.Array) {
|
|
||||||
_results.push(o.toJson(transform_to_value));
|
|
||||||
} else if (transform_to_value && o instanceof types.Operation) {
|
|
||||||
_results.push(o.val());
|
|
||||||
} else {
|
|
||||||
_results.push(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.val = function(pos) {
|
|
||||||
var o, result;
|
|
||||||
if (pos != null) {
|
|
||||||
o = this.getOperationByPosition(pos + 1);
|
|
||||||
if (!(o instanceof types.Delimiter)) {
|
|
||||||
return o.val();
|
|
||||||
} else {
|
|
||||||
throw new Error("this position does not exist");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
o = this.beginning.next_cl;
|
|
||||||
result = [];
|
|
||||||
while (o !== this.end) {
|
|
||||||
if (!o.isDeleted()) {
|
|
||||||
result.push(o.val());
|
|
||||||
}
|
|
||||||
o = o.next_cl;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.push = function(content) {
|
|
||||||
return this.insertAfter(this.end.prev_cl, content);
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.insertAfter = function(left, content, options) {
|
|
||||||
var c, createContent, right, tmp, _i, _len;
|
|
||||||
createContent = function(content, options) {
|
|
||||||
var type;
|
|
||||||
if ((content != null) && (content.constructor != null)) {
|
|
||||||
type = types[content.constructor.name];
|
|
||||||
if ((type != null) && (type.create != null)) {
|
|
||||||
return type.create(content, options);
|
|
||||||
} else {
|
|
||||||
throw new Error("The " + content.constructor.name + "-type is not (yet) supported in Y.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
right = left.next_cl;
|
|
||||||
while (right.isDeleted()) {
|
|
||||||
right = right.next_cl;
|
|
||||||
}
|
|
||||||
left = right.prev_cl;
|
|
||||||
if (content instanceof types.Operation) {
|
|
||||||
(new types.TextInsert(content, void 0, left, right)).execute();
|
|
||||||
} else {
|
|
||||||
for (_i = 0, _len = content.length; _i < _len; _i++) {
|
|
||||||
c = content[_i];
|
|
||||||
tmp = (new types.TextInsert(createContent(c, options), void 0, left, right)).execute();
|
|
||||||
left = tmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype.insert = function(position, content, options) {
|
|
||||||
var ith;
|
|
||||||
ith = this.getOperationByPosition(position);
|
|
||||||
return this.insertAfter(ith, [content], options);
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype["delete"] = function(position, length) {
|
|
||||||
var d, delete_ops, i, o, _i;
|
|
||||||
o = this.getOperationByPosition(position + 1);
|
|
||||||
delete_ops = [];
|
|
||||||
for (i = _i = 0; 0 <= length ? _i < length : _i > length; i = 0 <= length ? ++_i : --_i) {
|
|
||||||
if (o instanceof types.Delimiter) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
d = (new types.Delete(void 0, o)).execute();
|
|
||||||
o = o.next_cl;
|
|
||||||
while ((!(o instanceof types.Delimiter)) && o.isDeleted()) {
|
|
||||||
o = o.next_cl;
|
|
||||||
}
|
|
||||||
delete_ops.push(d._encode());
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.prototype._encode = function() {
|
|
||||||
var json;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid()
|
|
||||||
};
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Array;
|
|
||||||
|
|
||||||
})(types.ListManager);
|
|
||||||
types.Array.parse = function(json) {
|
|
||||||
var uid;
|
|
||||||
uid = json['uid'];
|
|
||||||
return new this(uid);
|
|
||||||
};
|
|
||||||
types.Array.create = function(content, mutable) {
|
|
||||||
var ith, list;
|
|
||||||
if (mutable === "mutable") {
|
|
||||||
list = new types.Array().execute();
|
|
||||||
ith = list.getOperationByPosition(0);
|
|
||||||
list.insertAfter(ith, content);
|
|
||||||
return list;
|
|
||||||
} else if ((mutable == null) || (mutable === "immutable")) {
|
|
||||||
return content;
|
|
||||||
} else {
|
|
||||||
throw new Error("Specify either \"mutable\" or \"immutable\"!!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
types.String = (function(_super) {
|
|
||||||
__extends(String, _super);
|
|
||||||
|
|
||||||
function String(uid) {
|
|
||||||
this.textfields = [];
|
|
||||||
String.__super__.constructor.call(this, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.type = "String";
|
|
||||||
|
|
||||||
String.prototype.val = function() {
|
|
||||||
var c, o;
|
|
||||||
c = (function() {
|
|
||||||
var _i, _len, _ref, _results;
|
|
||||||
_ref = this.toArray();
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
o = _ref[_i];
|
|
||||||
if (o.val != null) {
|
|
||||||
_results.push(o.val());
|
|
||||||
} else {
|
|
||||||
_results.push("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
}).call(this);
|
|
||||||
return c.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
String.prototype.toString = function() {
|
|
||||||
return this.val();
|
|
||||||
};
|
|
||||||
|
|
||||||
String.prototype.insert = function(position, content, options) {
|
|
||||||
var ith;
|
|
||||||
ith = this.getOperationByPosition(position);
|
|
||||||
return this.insertAfter(ith, content, options);
|
|
||||||
};
|
|
||||||
|
|
||||||
String.prototype.bind = function(textfield, dom_root) {
|
|
||||||
var createRange, creator_token, t, word, writeContent, writeRange, _i, _len, _ref;
|
|
||||||
if (dom_root == null) {
|
|
||||||
dom_root = window;
|
|
||||||
}
|
|
||||||
if (dom_root.getSelection == null) {
|
|
||||||
dom_root = window;
|
|
||||||
}
|
|
||||||
_ref = this.textfields;
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
t = _ref[_i];
|
|
||||||
if (t === textfield) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
creator_token = false;
|
|
||||||
word = this;
|
|
||||||
textfield.value = this.val();
|
|
||||||
this.textfields.push(textfield);
|
|
||||||
if ((textfield.selectionStart != null) && (textfield.setSelectionRange != null)) {
|
|
||||||
createRange = function(fix) {
|
|
||||||
var left, right;
|
|
||||||
left = textfield.selectionStart;
|
|
||||||
right = textfield.selectionEnd;
|
|
||||||
if (fix != null) {
|
|
||||||
left = fix(left);
|
|
||||||
right = fix(right);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
left: left,
|
|
||||||
right: right
|
|
||||||
};
|
|
||||||
};
|
|
||||||
writeRange = function(range) {
|
|
||||||
writeContent(word.val());
|
|
||||||
return textfield.setSelectionRange(range.left, range.right);
|
|
||||||
};
|
|
||||||
writeContent = function(content) {
|
|
||||||
return textfield.value = content;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
createRange = function(fix) {
|
|
||||||
var clength, left, right, s;
|
|
||||||
s = dom_root.getSelection();
|
|
||||||
clength = textfield.textContent.length;
|
|
||||||
left = Math.min(s.anchorOffset, clength);
|
|
||||||
right = Math.min(s.focusOffset, clength);
|
|
||||||
if (fix != null) {
|
|
||||||
left = fix(left);
|
|
||||||
right = fix(right);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
left: left,
|
|
||||||
right: right,
|
|
||||||
isReal: true
|
|
||||||
};
|
|
||||||
};
|
|
||||||
writeRange = function(range) {
|
|
||||||
var r, s, textnode;
|
|
||||||
writeContent(word.val());
|
|
||||||
textnode = textfield.childNodes[0];
|
|
||||||
if (range.isReal && (textnode != null)) {
|
|
||||||
if (range.left < 0) {
|
|
||||||
range.left = 0;
|
|
||||||
}
|
|
||||||
range.right = Math.max(range.left, range.right);
|
|
||||||
if (range.right > textnode.length) {
|
|
||||||
range.right = textnode.length;
|
|
||||||
}
|
|
||||||
range.left = Math.min(range.left, range.right);
|
|
||||||
r = document.createRange();
|
|
||||||
r.setStart(textnode, range.left);
|
|
||||||
r.setEnd(textnode, range.right);
|
|
||||||
s = window.getSelection();
|
|
||||||
s.removeAllRanges();
|
|
||||||
return s.addRange(r);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
writeContent = function(content) {
|
|
||||||
var append;
|
|
||||||
append = "";
|
|
||||||
if (content[content.length - 1] === " ") {
|
|
||||||
content = content.slice(0, content.length - 1);
|
|
||||||
append = ' ';
|
|
||||||
}
|
|
||||||
textfield.textContent = content;
|
|
||||||
return textfield.innerHTML += append;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
writeContent(this.val());
|
|
||||||
this.observe(function(events) {
|
|
||||||
var event, fix, o_pos, r, _j, _len1, _results;
|
|
||||||
_results = [];
|
|
||||||
for (_j = 0, _len1 = events.length; _j < _len1; _j++) {
|
|
||||||
event = events[_j];
|
|
||||||
if (!creator_token) {
|
|
||||||
if (event.type === "insert") {
|
|
||||||
o_pos = event.position;
|
|
||||||
fix = function(cursor) {
|
|
||||||
if (cursor <= o_pos) {
|
|
||||||
return cursor;
|
|
||||||
} else {
|
|
||||||
cursor += 1;
|
|
||||||
return cursor;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r = createRange(fix);
|
|
||||||
_results.push(writeRange(r));
|
|
||||||
} else if (event.type === "delete") {
|
|
||||||
o_pos = event.position;
|
|
||||||
fix = function(cursor) {
|
|
||||||
if (cursor < o_pos) {
|
|
||||||
return cursor;
|
|
||||||
} else {
|
|
||||||
cursor -= 1;
|
|
||||||
return cursor;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r = createRange(fix);
|
|
||||||
_results.push(writeRange(r));
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_results.push(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
});
|
|
||||||
textfield.onkeypress = function(event) {
|
|
||||||
var char, diff, pos, r;
|
|
||||||
if (word.is_deleted) {
|
|
||||||
textfield.onkeypress = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
creator_token = true;
|
|
||||||
char = null;
|
|
||||||
if (event.key != null) {
|
|
||||||
if (event.charCode === 32) {
|
|
||||||
char = " ";
|
|
||||||
} else if (event.keyCode === 13) {
|
|
||||||
char = '\n';
|
|
||||||
} else {
|
|
||||||
char = event.key;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
char = window.String.fromCharCode(event.keyCode);
|
|
||||||
}
|
|
||||||
if (char.length > 1) {
|
|
||||||
return true;
|
|
||||||
} else if (char.length > 0) {
|
|
||||||
r = createRange();
|
|
||||||
pos = Math.min(r.left, r.right);
|
|
||||||
diff = Math.abs(r.right - r.left);
|
|
||||||
word["delete"](pos, diff);
|
|
||||||
word.insert(pos, char);
|
|
||||||
r.left = pos + char.length;
|
|
||||||
r.right = r.left;
|
|
||||||
writeRange(r);
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
creator_token = false;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
textfield.onpaste = function(event) {
|
|
||||||
if (word.is_deleted) {
|
|
||||||
textfield.onpaste = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return event.preventDefault();
|
|
||||||
};
|
|
||||||
textfield.oncut = function(event) {
|
|
||||||
if (word.is_deleted) {
|
|
||||||
textfield.oncut = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return event.preventDefault();
|
|
||||||
};
|
|
||||||
return textfield.onkeydown = function(event) {
|
|
||||||
var del_length, diff, new_pos, pos, r, val;
|
|
||||||
creator_token = true;
|
|
||||||
if (word.is_deleted) {
|
|
||||||
textfield.onkeydown = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
r = createRange();
|
|
||||||
pos = Math.min(r.left, r.right, word.val().length);
|
|
||||||
diff = Math.abs(r.left - r.right);
|
|
||||||
if ((event.keyCode != null) && event.keyCode === 8) {
|
|
||||||
if (diff > 0) {
|
|
||||||
word["delete"](pos, diff);
|
|
||||||
r.left = pos;
|
|
||||||
r.right = pos;
|
|
||||||
writeRange(r);
|
|
||||||
} else {
|
|
||||||
if ((event.ctrlKey != null) && event.ctrlKey) {
|
|
||||||
val = word.val();
|
|
||||||
new_pos = pos;
|
|
||||||
del_length = 0;
|
|
||||||
if (pos > 0) {
|
|
||||||
new_pos--;
|
|
||||||
del_length++;
|
|
||||||
}
|
|
||||||
while (new_pos > 0 && val[new_pos] !== " " && val[new_pos] !== '\n') {
|
|
||||||
new_pos--;
|
|
||||||
del_length++;
|
|
||||||
}
|
|
||||||
word["delete"](new_pos, pos - new_pos);
|
|
||||||
r.left = new_pos;
|
|
||||||
r.right = new_pos;
|
|
||||||
writeRange(r);
|
|
||||||
} else {
|
|
||||||
if (pos > 0) {
|
|
||||||
word["delete"](pos - 1, 1);
|
|
||||||
r.left = pos - 1;
|
|
||||||
r.right = pos - 1;
|
|
||||||
writeRange(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
creator_token = false;
|
|
||||||
return false;
|
|
||||||
} else if ((event.keyCode != null) && event.keyCode === 46) {
|
|
||||||
if (diff > 0) {
|
|
||||||
word["delete"](pos, diff);
|
|
||||||
r.left = pos;
|
|
||||||
r.right = pos;
|
|
||||||
writeRange(r);
|
|
||||||
} else {
|
|
||||||
word["delete"](pos, 1);
|
|
||||||
r.left = pos;
|
|
||||||
r.right = pos;
|
|
||||||
writeRange(r);
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
creator_token = false;
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
creator_token = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
String.prototype._encode = function() {
|
|
||||||
var json;
|
|
||||||
json = {
|
|
||||||
'type': this.type,
|
|
||||||
'uid': this.getUid()
|
|
||||||
};
|
|
||||||
return json;
|
|
||||||
};
|
|
||||||
|
|
||||||
return String;
|
|
||||||
|
|
||||||
})(types.Array);
|
|
||||||
types.String.parse = function(json) {
|
|
||||||
var uid;
|
|
||||||
uid = json['uid'];
|
|
||||||
return new this(uid);
|
|
||||||
};
|
|
||||||
types.String.create = function(content, mutable) {
|
|
||||||
var word;
|
|
||||||
if (mutable === "mutable") {
|
|
||||||
word = new types.String().execute();
|
|
||||||
word.insert(0, content);
|
|
||||||
return word;
|
|
||||||
} else if ((mutable == null) || (mutable === "immutable")) {
|
|
||||||
return content;
|
|
||||||
} else {
|
|
||||||
throw new Error("Specify either \"mutable\" or \"immutable\"!!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return structured_types;
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Test Yatta!</title>
|
|
||||||
<link rel="stylesheet" href="../../node_modules/mocha/mocha.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="mocha"></div>
|
|
||||||
<div id="test_dom" test_attribute="the test" class="stuffy" style="color: blue"><p id="replaceme">replace me</p><p id="removeme">remove me</p><p>This is a test object for <b>XmlFramework</b></p><span class="span_element"><p>span</p></span></div>
|
|
||||||
<script src="../../node_modules/mocha/mocha.js" class="awesome"></script>
|
|
||||||
<script>
|
|
||||||
mocha.setup('bdd');
|
|
||||||
mocha.ui('bdd');
|
|
||||||
mocha.reporter('html');
|
|
||||||
</script>
|
|
||||||
<script src="TextYatta_test.js"></script>
|
|
||||||
<script src="JsonYatta_test.js"></script>
|
|
||||||
<!--script src="XmlYatta_test_browser.js"></script-->
|
|
||||||
<script>
|
|
||||||
//mocha.checkLeaks();
|
|
||||||
//mocha.run();
|
|
||||||
window.onerror = null;
|
|
||||||
if (window.mochaPhantomJS) { mochaPhantomJS.run(); }
|
|
||||||
else { mocha.run(); }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Examples
|
|
||||||
|
|
||||||
Here you find some (hopefully) usefull examples on how to use Yatta!
|
|
||||||
|
|
||||||
Please note, that the XMPP Connector is the best supported Connector at the moment.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!DOCTYPE HTML>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset=utf-8 />
|
|
||||||
<title>Y Example</title>
|
|
||||||
<script src="../../../webcomponentsjs/webcomponents.min.js"></script>
|
|
||||||
<link rel="import" href="../../../polymer/polymer.html">
|
|
||||||
|
|
||||||
<link rel="import" href="y-test.html">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<y-test></y-test>
|
|
||||||
<script src="./index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
|
|
||||||
setTimeout(function(){
|
|
||||||
window.y_test = document.querySelector("y-test");
|
|
||||||
|
|
||||||
window.y_test.y.val("stuff",{otherstuff:{nostuff:"this is no stuff"}})
|
|
||||||
setTimeout(function(){
|
|
||||||
var res = y_test.y.val("stuff");
|
|
||||||
if(!(y_test.nostuff === "this is no stuff")){
|
|
||||||
console.log("Deep inherit doesn't work!")
|
|
||||||
}
|
|
||||||
window.y_stuff_property.val = {nostuff: "this is also no stuff"};
|
|
||||||
setTimeout(function(){
|
|
||||||
if(!(y_test.nostuff === "this is also no stuff")){
|
|
||||||
console.log("Element val overwrite doesn't work")
|
|
||||||
}
|
|
||||||
console.log("Everything is fine :)");
|
|
||||||
},500)
|
|
||||||
},500);
|
|
||||||
},3000)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset=utf-8 />
|
|
||||||
<title>Y Example</title>
|
|
||||||
<script src="../../build/browser/y.js"></script>
|
|
||||||
<script src="../../../y-connectors/y-xmpp/y-xmpp.js"></script>
|
|
||||||
<script src="./index.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 contentEditable> yjs Tutorial</h1>
|
|
||||||
<p> Collaborative Json editing with <a href="https://github.com/rwth-acis/yjs/">yjs</a>
|
|
||||||
and XMPP Connector. </p>
|
|
||||||
|
|
||||||
<textarea style="width:80%;" rows=40 id="textfield"></textarea>
|
|
||||||
|
|
||||||
<p> <a href="https://github.com/rwth-acis/yjs/">yjs</a> is a Framework for Real-Time collaboration on arbitrary data types.
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
connector = new Y.XMPP("testy-xmpp-json2");
|
|
||||||
connector.debug = true
|
|
||||||
|
|
||||||
y = new Y(connector);
|
|
||||||
|
|
||||||
window.onload = function(){
|
|
||||||
var textbox = document.getElementById("textfield");
|
|
||||||
y.observe(function(events){
|
|
||||||
for(var i=0; i<events.length; i++){
|
|
||||||
var event = events[i];
|
|
||||||
if(event.name === "textfield" && event.type !== "delete"){
|
|
||||||
y.val("textfield").bind(textbox);
|
|
||||||
y.val("headline").bind(document.querySelector("h1"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connector.whenSynced(function(){
|
|
||||||
if(y.val("textfield") == null){
|
|
||||||
y.val("headline","headline", "mutable");
|
|
||||||
y.val("textfield","stuff", "mutable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
};
|
|
||||||
122
gulpfile.coffee
122
gulpfile.coffee
@@ -1,122 +0,0 @@
|
|||||||
gulp = require('gulp')
|
|
||||||
coffee = require('gulp-coffee')
|
|
||||||
concat = require('gulp-concat')
|
|
||||||
uglify = require 'gulp-uglify'
|
|
||||||
sourcemaps = require('gulp-sourcemaps')
|
|
||||||
browserify = require('gulp-browserify')
|
|
||||||
rename = require 'gulp-rename'
|
|
||||||
rimraf = require 'gulp-rimraf'
|
|
||||||
gulpif = require 'gulp-if'
|
|
||||||
ignore = require 'gulp-ignore'
|
|
||||||
git = require 'gulp-git'
|
|
||||||
debug = require 'gulp-debug'
|
|
||||||
coffeelint = require 'gulp-coffeelint'
|
|
||||||
mocha = require 'gulp-mocha'
|
|
||||||
run = require 'gulp-run'
|
|
||||||
ljs = require 'gulp-ljs'
|
|
||||||
plumber = require 'gulp-plumber'
|
|
||||||
mochaPhantomJS = require 'gulp-mocha-phantomjs'
|
|
||||||
cache = require 'gulp-cached'
|
|
||||||
coffeeify = require 'gulp-coffeeify'
|
|
||||||
|
|
||||||
gulp.task 'default', ['build_browser']
|
|
||||||
|
|
||||||
files =
|
|
||||||
lib : ['./lib/**/*.coffee']
|
|
||||||
browser : ['./lib/y.coffee','./lib/y-object.coffee']
|
|
||||||
#test : ['./test/**/*_test.coffee']
|
|
||||||
test : ['./test/Json_test.coffee', './test/Text_test.coffee']
|
|
||||||
gulp : ['./gulpfile.coffee']
|
|
||||||
examples : ['./examples/**/*.js']
|
|
||||||
other: ['./lib/**/*']
|
|
||||||
|
|
||||||
files.all = []
|
|
||||||
for name,file_list of files
|
|
||||||
if name isnt 'build'
|
|
||||||
files.all = files.all.concat file_list
|
|
||||||
|
|
||||||
gulp.task 'deploy_nodejs', ->
|
|
||||||
gulp.src files.lib
|
|
||||||
.pipe sourcemaps.init()
|
|
||||||
.pipe coffee()
|
|
||||||
.pipe sourcemaps.write './'
|
|
||||||
.pipe gulp.dest 'build/node/'
|
|
||||||
.pipe gulpif '!**/', git.add({args : "-A"})
|
|
||||||
|
|
||||||
gulp.task 'deploy', ['mocha', 'build_browser', 'deploy_nodejs', 'lint', 'phantom_test', 'codo']
|
|
||||||
|
|
||||||
gulp.task 'build_browser', ->
|
|
||||||
gulp.src files.browser, { read: false }
|
|
||||||
.pipe plumber()
|
|
||||||
.pipe browserify
|
|
||||||
transform: ['coffeeify']
|
|
||||||
extensions: ['.coffee']
|
|
||||||
debug : true
|
|
||||||
.pipe rename
|
|
||||||
extname: ".js"
|
|
||||||
.pipe gulp.dest './build/browser/'
|
|
||||||
.pipe uglify()
|
|
||||||
.pipe gulp.dest '.'
|
|
||||||
|
|
||||||
gulp.src files.test, {read: false}
|
|
||||||
.pipe plumber()
|
|
||||||
.pipe browserify
|
|
||||||
transform: ['coffeeify']
|
|
||||||
extensions: ['.coffee']
|
|
||||||
debug: true
|
|
||||||
.pipe rename
|
|
||||||
extname: ".js"
|
|
||||||
.pipe gulp.dest './build/test/'
|
|
||||||
|
|
||||||
gulp.task 'build_node', ->
|
|
||||||
gulp.src files.lib
|
|
||||||
.pipe plumber()
|
|
||||||
.pipe coffee({bare:true})
|
|
||||||
.pipe gulp.dest './build/node'
|
|
||||||
|
|
||||||
gulp.task 'build', ['build_node', 'build_browser'], ->
|
|
||||||
|
|
||||||
gulp.task 'watch', ['build_browser'], ->
|
|
||||||
gulp.watch files.all, ['build_browser']
|
|
||||||
|
|
||||||
gulp.task 'mocha', ->
|
|
||||||
gulp.src files.test, { read: false }
|
|
||||||
.pipe plumber()
|
|
||||||
.pipe mocha {reporter : 'list'}
|
|
||||||
|
|
||||||
|
|
||||||
gulp.task 'lint', ->
|
|
||||||
gulp.src files.all
|
|
||||||
.pipe ignore.include '**/*.coffee'
|
|
||||||
.pipe coffeelint {
|
|
||||||
"max_line_length":
|
|
||||||
"level": "ignore"
|
|
||||||
}
|
|
||||||
.pipe coffeelint.reporter()
|
|
||||||
|
|
||||||
gulp.task 'phantom_watch', ['phantom_test'], ->
|
|
||||||
gulp.watch files.all, ['phantom_test']
|
|
||||||
|
|
||||||
gulp.task 'literate', ->
|
|
||||||
gulp.src files.examples
|
|
||||||
.pipe ljs { code : true }
|
|
||||||
.pipe rename
|
|
||||||
basename : "README"
|
|
||||||
extname : ".md"
|
|
||||||
.pipe gulp.dest 'examples/'
|
|
||||||
.pipe gulpif '!**/', git.add({args : "-A"})
|
|
||||||
|
|
||||||
gulp.task 'codo', [], ()->
|
|
||||||
command = './node_modules/codo/bin/codo -o "./doc" --name "yjs" --readme "README.md" --undocumented false --private true --title "yjs API" ./lib - LICENSE.txt '
|
|
||||||
run(command).exec()
|
|
||||||
|
|
||||||
gulp.task 'phantom_test', ['build_browser'], ()->
|
|
||||||
gulp.src 'build/test/index.html'
|
|
||||||
.pipe mochaPhantomJS()
|
|
||||||
|
|
||||||
gulp.task 'clean', ->
|
|
||||||
gulp.src ['./build/{browser,test,node}/**/*.{js,map}','./doc/'], { read: false }
|
|
||||||
.pipe rimraf()
|
|
||||||
|
|
||||||
gulp.task 'default', ['clean','build'], ->
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Engine} engine The transformation engine
|
|
||||||
# @param {HistoryBuffer} HB
|
|
||||||
# @param {Array<Function>} execution_listener You must ensure that whenever an operation is executed, every function in this Array is called.
|
|
||||||
#
|
|
||||||
adaptConnector = (connector, engine, HB, execution_listener)->
|
|
||||||
send_ = (o)->
|
|
||||||
if o.uid.creator is HB.getUserId() and (typeof o.uid.op_number isnt "string")
|
|
||||||
connector.broadcast o
|
|
||||||
|
|
||||||
if connector.invokeSync?
|
|
||||||
HB.setInvokeSyncHandler connector.invokeSync
|
|
||||||
|
|
||||||
execution_listener.push send_
|
|
||||||
# For the XMPPConnector: lets send it as an array
|
|
||||||
# therefore, we have to restructure it later
|
|
||||||
encode_state_vector = (v)->
|
|
||||||
for name,value of v
|
|
||||||
user: name
|
|
||||||
state: value
|
|
||||||
parse_state_vector = (v)->
|
|
||||||
state_vector = {}
|
|
||||||
for s in v
|
|
||||||
state_vector[s.user] = s.state
|
|
||||||
state_vector
|
|
||||||
|
|
||||||
getStateVector = ()->
|
|
||||||
encode_state_vector HB.getOperationCounter()
|
|
||||||
|
|
||||||
getHB = (v)->
|
|
||||||
state_vector = parse_state_vector v
|
|
||||||
hb = HB._encode state_vector
|
|
||||||
for o in hb
|
|
||||||
o.fromHB = "true" # execute immediately
|
|
||||||
json =
|
|
||||||
hb: hb
|
|
||||||
state_vector: encode_state_vector HB.getOperationCounter()
|
|
||||||
json
|
|
||||||
|
|
||||||
applyHB = (hb)->
|
|
||||||
engine.applyOp hb
|
|
||||||
|
|
||||||
connector.getStateVector = getStateVector
|
|
||||||
connector.getHB = getHB
|
|
||||||
connector.applyHB = applyHB
|
|
||||||
|
|
||||||
connector.whenReceiving (sender, op)->
|
|
||||||
if op.uid.creator isnt HB.getUserId()
|
|
||||||
engine.applyOp op
|
|
||||||
|
|
||||||
if connector._whenBoundToY?
|
|
||||||
connector._whenBoundToY()
|
|
||||||
|
|
||||||
module.exports = adaptConnector
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
|
|
||||||
window?.unprocessed_counter = 0 # del this
|
|
||||||
window?.unprocessed_exec_counter = 0 # TODO
|
|
||||||
window?.unprocessed_types = []
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# The Engine handles how and in which order to execute operations and add operations to the HistoryBuffer.
|
|
||||||
#
|
|
||||||
class Engine
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {HistoryBuffer} HB
|
|
||||||
# @param {Object} types list of available types
|
|
||||||
#
|
|
||||||
constructor: (@HB, @types)->
|
|
||||||
@unprocessed_ops = []
|
|
||||||
|
|
||||||
#
|
|
||||||
# Parses an operatio from the json format. It uses the specified parser in your OperationType module.
|
|
||||||
#
|
|
||||||
parseOperation: (json)->
|
|
||||||
type = @types[json.type]
|
|
||||||
if type?.parse?
|
|
||||||
type.parse json
|
|
||||||
else
|
|
||||||
throw new Error "You forgot to specify a parser for type #{json.type}. The message is #{JSON.stringify json}."
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Apply a set of operations. E.g. the operations you received from another users HB._encode().
|
|
||||||
# @note You must not use this method when you already have ops in your HB!
|
|
||||||
###
|
|
||||||
applyOpsBundle: (ops_json)->
|
|
||||||
ops = []
|
|
||||||
for o in ops_json
|
|
||||||
ops.push @parseOperation o
|
|
||||||
for o in ops
|
|
||||||
if not o.execute()
|
|
||||||
@unprocessed_ops.push o
|
|
||||||
@tryUnprocessed()
|
|
||||||
###
|
|
||||||
|
|
||||||
#
|
|
||||||
# Same as applyOps but operations that are already in the HB are not applied.
|
|
||||||
# @see Engine.applyOps
|
|
||||||
#
|
|
||||||
applyOpsCheckDouble: (ops_json)->
|
|
||||||
for o in ops_json
|
|
||||||
if not @HB.getOperation(o.uid)?
|
|
||||||
@applyOp o
|
|
||||||
|
|
||||||
#
|
|
||||||
# Apply a set of operations. (Helper for using applyOp on Arrays)
|
|
||||||
# @see Engine.applyOp
|
|
||||||
applyOps: (ops_json)->
|
|
||||||
@applyOp ops_json
|
|
||||||
|
|
||||||
#
|
|
||||||
# Apply an operation that you received from another peer.
|
|
||||||
# TODO: make this more efficient!!
|
|
||||||
# - operations may only executed in order by creator, order them in object of arrays (key by creator)
|
|
||||||
# - you can probably make something like dependencies (creator1 waits for creator2)
|
|
||||||
applyOp: (op_json_array)->
|
|
||||||
if op_json_array.constructor isnt Array
|
|
||||||
op_json_array = [op_json_array]
|
|
||||||
for op_json in op_json_array
|
|
||||||
# $parse_and_execute will return false if $o_json was parsed and executed, otherwise the parsed operadion
|
|
||||||
o = @parseOperation op_json
|
|
||||||
if op_json.fromHB?
|
|
||||||
o.fromHB = op_json.fromHB
|
|
||||||
# @HB.addOperation o
|
|
||||||
if @HB.getOperation(o)?
|
|
||||||
# nop
|
|
||||||
else if ((not @HB.isExpectedOperation(o)) and (not o.fromHB?)) or (not o.execute())
|
|
||||||
@unprocessed_ops.push o
|
|
||||||
window?.unprocessed_types.push o.type # TODO: delete this
|
|
||||||
@tryUnprocessed()
|
|
||||||
|
|
||||||
#
|
|
||||||
# Call this method when you applied a new operation.
|
|
||||||
# It checks if operations that were previously not executable are now executable.
|
|
||||||
#
|
|
||||||
tryUnprocessed: ()->
|
|
||||||
while true
|
|
||||||
old_length = @unprocessed_ops.length
|
|
||||||
unprocessed = []
|
|
||||||
for op in @unprocessed_ops
|
|
||||||
if @HB.getOperation(op)?
|
|
||||||
# nop
|
|
||||||
else if (not @HB.isExpectedOperation(op) and (not op.fromHB?)) or (not op.execute())
|
|
||||||
unprocessed.push op
|
|
||||||
@unprocessed_ops = unprocessed
|
|
||||||
if @unprocessed_ops.length is old_length
|
|
||||||
break
|
|
||||||
if @unprocessed_ops.length isnt 0
|
|
||||||
@HB.invokeSync()
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = Engine
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# An object that holds all applied operations.
|
|
||||||
#
|
|
||||||
# @note The HistoryBuffer is commonly abbreviated to HB.
|
|
||||||
#
|
|
||||||
class HistoryBuffer
|
|
||||||
|
|
||||||
#
|
|
||||||
# Creates an empty HB.
|
|
||||||
# @param {Object} user_id Creator of the HB.
|
|
||||||
#
|
|
||||||
constructor: (@user_id)->
|
|
||||||
@operation_counter = {}
|
|
||||||
@buffer = {}
|
|
||||||
@change_listeners = []
|
|
||||||
@garbage = [] # Will be cleaned on next call of garbageCollector
|
|
||||||
@trash = [] # Is deleted. Wait until it is not used anymore.
|
|
||||||
@performGarbageCollection = true
|
|
||||||
@garbageCollectTimeout = 30000
|
|
||||||
@reserved_identifier_counter = 0
|
|
||||||
setTimeout @emptyGarbage, @garbageCollectTimeout
|
|
||||||
|
|
||||||
resetUserId: (id)->
|
|
||||||
own = @buffer[@user_id]
|
|
||||||
if own?
|
|
||||||
for o_name,o of own
|
|
||||||
o.uid.creator = id
|
|
||||||
if @buffer[id]?
|
|
||||||
throw new Error "You are re-assigning an old user id - this is not (yet) possible!"
|
|
||||||
@buffer[id] = own
|
|
||||||
delete @buffer[@user_id]
|
|
||||||
|
|
||||||
@operation_counter[id] = @operation_counter[@user_id]
|
|
||||||
delete @operation_counter[@user_id]
|
|
||||||
@user_id = id
|
|
||||||
|
|
||||||
emptyGarbage: ()=>
|
|
||||||
for o in @garbage
|
|
||||||
#if @getOperationCounter(o.uid.creator) > o.uid.op_number
|
|
||||||
o.cleanup?()
|
|
||||||
|
|
||||||
@garbage = @trash
|
|
||||||
@trash = []
|
|
||||||
if @garbageCollectTimeout isnt -1
|
|
||||||
@garbageCollectTimeoutId = setTimeout @emptyGarbage, @garbageCollectTimeout
|
|
||||||
undefined
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the user id with wich the History Buffer was initialized.
|
|
||||||
#
|
|
||||||
getUserId: ()->
|
|
||||||
@user_id
|
|
||||||
|
|
||||||
addToGarbageCollector: ()->
|
|
||||||
if @performGarbageCollection
|
|
||||||
for o in arguments
|
|
||||||
if o?
|
|
||||||
@garbage.push o
|
|
||||||
|
|
||||||
stopGarbageCollection: ()->
|
|
||||||
@performGarbageCollection = false
|
|
||||||
@setManualGarbageCollect()
|
|
||||||
@garbage = []
|
|
||||||
@trash = []
|
|
||||||
|
|
||||||
setManualGarbageCollect: ()->
|
|
||||||
@garbageCollectTimeout = -1
|
|
||||||
clearTimeout @garbageCollectTimeoutId
|
|
||||||
@garbageCollectTimeoutId = undefined
|
|
||||||
|
|
||||||
setGarbageCollectTimeout: (@garbageCollectTimeout)->
|
|
||||||
|
|
||||||
#
|
|
||||||
# I propose to use it in your Framework, to create something like a root element.
|
|
||||||
# An operation with this identifier is not propagated to other clients.
|
|
||||||
# This is why everybode must create the same operation with this uid.
|
|
||||||
#
|
|
||||||
getReservedUniqueIdentifier: ()->
|
|
||||||
{
|
|
||||||
creator : '_'
|
|
||||||
op_number : "_#{@reserved_identifier_counter++}"
|
|
||||||
doSync: false
|
|
||||||
}
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the operation counter that describes the current state of the document.
|
|
||||||
#
|
|
||||||
getOperationCounter: (user_id)->
|
|
||||||
if not user_id?
|
|
||||||
res = {}
|
|
||||||
for user,ctn of @operation_counter
|
|
||||||
res[user] = ctn
|
|
||||||
res
|
|
||||||
else
|
|
||||||
@operation_counter[user_id]
|
|
||||||
|
|
||||||
isExpectedOperation: (o)->
|
|
||||||
@operation_counter[o.uid.creator] ?= 0
|
|
||||||
o.uid.op_number <= @operation_counter[o.uid.creator]
|
|
||||||
true #TODO: !! this could break stuff. But I dunno why
|
|
||||||
|
|
||||||
#
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
# TODO: Make this more efficient!
|
|
||||||
_encode: (state_vector={})->
|
|
||||||
json = []
|
|
||||||
unknown = (user, o_number)->
|
|
||||||
if (not user?) or (not o_number?)
|
|
||||||
throw new Error "dah!"
|
|
||||||
not state_vector[user]? or state_vector[user] <= o_number
|
|
||||||
|
|
||||||
for u_name,user of @buffer
|
|
||||||
# TODO next, if @state_vector[user] <= state_vector[user]
|
|
||||||
for o_number,o of user
|
|
||||||
if o.uid.doSync and unknown(u_name, o_number)
|
|
||||||
# its necessary to send it, and not known in state_vector
|
|
||||||
o_json = o._encode()
|
|
||||||
if o.next_cl? # applies for all ops but the most right delimiter!
|
|
||||||
# search for the next _known_ operation. (When state_vector is {} then this is the Delimiter)
|
|
||||||
o_next = o.next_cl
|
|
||||||
while o_next.next_cl? and unknown(o_next.uid.creator, o_next.uid.op_number)
|
|
||||||
o_next = o_next.next_cl
|
|
||||||
o_json.next = o_next.getUid()
|
|
||||||
else if o.prev_cl? # most right delimiter only!
|
|
||||||
# same as the above with prev.
|
|
||||||
o_prev = o.prev_cl
|
|
||||||
while o_prev.prev_cl? and unknown(o_prev.uid.creator, o_prev.uid.op_number)
|
|
||||||
o_prev = o_prev.prev_cl
|
|
||||||
o_json.prev = o_prev.getUid()
|
|
||||||
json.push o_json
|
|
||||||
|
|
||||||
json
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the number of operations that were created by a user.
|
|
||||||
# Accordingly you will get the next operation number that is expected from that user.
|
|
||||||
# This will increment the operation counter.
|
|
||||||
#
|
|
||||||
getNextOperationIdentifier: (user_id)->
|
|
||||||
if not user_id?
|
|
||||||
user_id = @user_id
|
|
||||||
if not @operation_counter[user_id]?
|
|
||||||
@operation_counter[user_id] = 0
|
|
||||||
uid =
|
|
||||||
'creator' : user_id
|
|
||||||
'op_number' : @operation_counter[user_id]
|
|
||||||
'doSync' : true
|
|
||||||
@operation_counter[user_id]++
|
|
||||||
uid
|
|
||||||
|
|
||||||
#
|
|
||||||
# Retrieve an operation from a unique id.
|
|
||||||
#
|
|
||||||
# when uid has a "sub" property, the value of it will be applied
|
|
||||||
# on the operations retrieveSub method (which must! be defined)
|
|
||||||
#
|
|
||||||
getOperation: (uid)->
|
|
||||||
if uid.uid?
|
|
||||||
uid = uid.uid
|
|
||||||
o = @buffer[uid.creator]?[uid.op_number]
|
|
||||||
if uid.sub? and o?
|
|
||||||
o.retrieveSub uid.sub
|
|
||||||
else
|
|
||||||
o
|
|
||||||
|
|
||||||
#
|
|
||||||
# Add an operation to the HB. Note that this will not link it against
|
|
||||||
# other operations (it wont executed)
|
|
||||||
#
|
|
||||||
addOperation: (o)->
|
|
||||||
if not @buffer[o.uid.creator]?
|
|
||||||
@buffer[o.uid.creator] = {}
|
|
||||||
if @buffer[o.uid.creator][o.uid.op_number]?
|
|
||||||
throw new Error "You must not overwrite operations!"
|
|
||||||
if (o.uid.op_number.constructor isnt String) and (not @isExpectedOperation(o)) and (not o.fromHB?) # you already do this in the engine, so delete it here!
|
|
||||||
throw new Error "this operation was not expected!"
|
|
||||||
@addToCounter(o)
|
|
||||||
@buffer[o.uid.creator][o.uid.op_number] = o
|
|
||||||
o
|
|
||||||
|
|
||||||
removeOperation: (o)->
|
|
||||||
delete @buffer[o.uid.creator]?[o.uid.op_number]
|
|
||||||
|
|
||||||
# When the HB determines inconsistencies, then the invokeSync
|
|
||||||
# handler wil be called, which should somehow invoke the sync with another collaborator.
|
|
||||||
# The parameter of the sync handler is the user_id with wich an inconsistency was determined
|
|
||||||
setInvokeSyncHandler: (f)->
|
|
||||||
@invokeSync = f
|
|
||||||
|
|
||||||
# empty per default # TODO: do i need this?
|
|
||||||
invokeSync: ()->
|
|
||||||
|
|
||||||
# after you received the HB of another user (in the sync process),
|
|
||||||
# you renew your own state_vector to the state_vector of the other user
|
|
||||||
renewStateVector: (state_vector)->
|
|
||||||
for user,state of state_vector
|
|
||||||
if (not @operation_counter[user]?) or (@operation_counter[user] < state_vector[user])
|
|
||||||
@operation_counter[user] = state_vector[user]
|
|
||||||
|
|
||||||
#
|
|
||||||
# Increment the operation_counter that defines the current state of the Engine.
|
|
||||||
#
|
|
||||||
addToCounter: (o)->
|
|
||||||
if not @operation_counter[o.uid.creator]?
|
|
||||||
@operation_counter[o.uid.creator] = 0
|
|
||||||
if typeof o.uid.op_number is 'number' and o.uid.creator isnt @getUserId()
|
|
||||||
# TODO: check if operations are send in order
|
|
||||||
if o.uid.op_number is @operation_counter[o.uid.creator]
|
|
||||||
@operation_counter[o.uid.creator]++
|
|
||||||
else
|
|
||||||
@invokeSync o.uid.creator
|
|
||||||
|
|
||||||
#if @operation_counter[o.uid.creator] isnt (o.uid.op_number + 1)
|
|
||||||
#console.log (@operation_counter[o.uid.creator] - (o.uid.op_number + 1))
|
|
||||||
#console.log o
|
|
||||||
#throw new Error "You don't receive operations in the proper order. Try counting like this 0,1,2,3,4,.. ;)"
|
|
||||||
|
|
||||||
module.exports = HistoryBuffer
|
|
||||||
@@ -1,555 +0,0 @@
|
|||||||
module.exports = (HB)->
|
|
||||||
# @see Engine.parse
|
|
||||||
types = {}
|
|
||||||
execution_listener = []
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# @abstract
|
|
||||||
# @nodoc
|
|
||||||
# A generic interface to operations.
|
|
||||||
#
|
|
||||||
# An operation has the following methods:
|
|
||||||
# * _encode: encodes an operation (needed only if instance of this operation is sent).
|
|
||||||
# * execute: execute the effects of this operations. Good examples are Insert-type and AddName-type
|
|
||||||
# * val: in the case that the operation holds a value
|
|
||||||
#
|
|
||||||
# Furthermore an encodable operation has a parser. We extend the parser object in order to parse encoded operations.
|
|
||||||
#
|
|
||||||
class types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier.
|
|
||||||
# If uid is undefined, a new uid will be created before at the end of the execution sequence
|
|
||||||
#
|
|
||||||
constructor: (uid)->
|
|
||||||
@is_deleted = false
|
|
||||||
@garbage_collected = false
|
|
||||||
@event_listeners = [] # TODO: rename to observers or sth like that
|
|
||||||
if uid?
|
|
||||||
@uid = uid
|
|
||||||
|
|
||||||
type: "Operation"
|
|
||||||
|
|
||||||
retrieveSub: ()->
|
|
||||||
throw new Error "sub properties are not enable on this operation type!"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Add an event listener. It depends on the operation which events are supported.
|
|
||||||
# @param {Function} f f is executed in case the event fires.
|
|
||||||
#
|
|
||||||
observe: (f)->
|
|
||||||
@event_listeners.push f
|
|
||||||
|
|
||||||
#
|
|
||||||
# Deletes function from the observer list
|
|
||||||
# @see Operation.observe
|
|
||||||
#
|
|
||||||
# @overload unobserve(event, f)
|
|
||||||
# @param f {Function} The function that you want to delete
|
|
||||||
unobserve: (f)->
|
|
||||||
@event_listeners = @event_listeners.filter (g)->
|
|
||||||
f isnt g
|
|
||||||
|
|
||||||
#
|
|
||||||
# Deletes all subscribed event listeners.
|
|
||||||
# This should be called, e.g. after this has been replaced.
|
|
||||||
# (Then only one replace event should fire. )
|
|
||||||
# This is also called in the cleanup method.
|
|
||||||
deleteAllObservers: ()->
|
|
||||||
@event_listeners = []
|
|
||||||
|
|
||||||
delete: ()->
|
|
||||||
(new types.Delete undefined, @).execute()
|
|
||||||
null
|
|
||||||
|
|
||||||
#
|
|
||||||
# Fire an event.
|
|
||||||
# TODO: Do something with timeouts. You don't want this to fire for every operation (e.g. insert).
|
|
||||||
# TODO: do you need callEvent+forwardEvent? Only one suffices probably
|
|
||||||
callEvent: ()->
|
|
||||||
@forwardEvent @, arguments...
|
|
||||||
|
|
||||||
#
|
|
||||||
# Fire an event and specify in which context the listener is called (set 'this').
|
|
||||||
# TODO: do you need this ?
|
|
||||||
forwardEvent: (op, args...)->
|
|
||||||
for f in @event_listeners
|
|
||||||
f.call op, args...
|
|
||||||
|
|
||||||
isDeleted: ()->
|
|
||||||
@is_deleted
|
|
||||||
|
|
||||||
applyDelete: (garbagecollect = true)->
|
|
||||||
if not @garbage_collected
|
|
||||||
#console.log "applyDelete: #{@type}"
|
|
||||||
@is_deleted = true
|
|
||||||
if garbagecollect
|
|
||||||
@garbage_collected = true
|
|
||||||
HB.addToGarbageCollector @
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
#console.log "cleanup: #{@type}"
|
|
||||||
HB.removeOperation @
|
|
||||||
@deleteAllObservers()
|
|
||||||
|
|
||||||
#
|
|
||||||
# Set the parent of this operation.
|
|
||||||
#
|
|
||||||
setParent: (@parent)->
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the parent of this operation.
|
|
||||||
#
|
|
||||||
getParent: ()->
|
|
||||||
@parent
|
|
||||||
|
|
||||||
#
|
|
||||||
# Computes a unique identifier (uid) that identifies this operation.
|
|
||||||
#
|
|
||||||
getUid: ()->
|
|
||||||
if not @uid.noOperation?
|
|
||||||
@uid
|
|
||||||
else
|
|
||||||
@uid.alt # could be (safely) undefined
|
|
||||||
|
|
||||||
cloneUid: ()->
|
|
||||||
uid = {}
|
|
||||||
for n,v of @getUid()
|
|
||||||
uid[n] = v
|
|
||||||
uid
|
|
||||||
|
|
||||||
dontSync: ()->
|
|
||||||
@uid.doSync = false
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# If not already done, set the uid
|
|
||||||
# Add this to the HB
|
|
||||||
# Notify the all the listeners.
|
|
||||||
#
|
|
||||||
execute: ()->
|
|
||||||
@is_executed = true
|
|
||||||
if not @uid?
|
|
||||||
# When this operation was created without a uid, then set it here.
|
|
||||||
# There is only one other place, where this can be done - before an Insertion
|
|
||||||
# is executed (because we need the creator_id)
|
|
||||||
@uid = HB.getNextOperationIdentifier()
|
|
||||||
if not @uid.noOperation?
|
|
||||||
HB.addOperation @
|
|
||||||
for l in execution_listener
|
|
||||||
l @_encode()
|
|
||||||
@
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Operations may depend on other operations (linked lists, etc.).
|
|
||||||
# The saveOperation and validateSavedOperations methods provide
|
|
||||||
# an easy way to refer to these operations via an uid or object reference.
|
|
||||||
#
|
|
||||||
# For example: We can create a new Delete operation that deletes the operation $o like this
|
|
||||||
# - var d = new Delete(uid, $o); or
|
|
||||||
# - var d = new Delete(uid, $o.getUid());
|
|
||||||
# Either way we want to access $o via d.deletes. In the second case validateSavedOperations must be called first.
|
|
||||||
#
|
|
||||||
# @overload saveOperation(name, op_uid)
|
|
||||||
# @param {String} name The name of the operation. After validating (with validateSavedOperations) the instantiated operation will be accessible via this[name].
|
|
||||||
# @param {Object} op_uid A uid that refers to an operation
|
|
||||||
# @overload saveOperation(name, op)
|
|
||||||
# @param {String} name The name of the operation. After calling this function op is accessible via this[name].
|
|
||||||
# @param {Operation} op An Operation object
|
|
||||||
#
|
|
||||||
saveOperation: (name, op)->
|
|
||||||
|
|
||||||
#
|
|
||||||
# Every instance of $Operation must have an $execute function.
|
|
||||||
# We use duck-typing to check if op is instantiated since there
|
|
||||||
# could exist multiple classes of $Operation
|
|
||||||
#
|
|
||||||
if op?.execute?
|
|
||||||
# is instantiated
|
|
||||||
@[name] = op
|
|
||||||
else if op?
|
|
||||||
# not initialized. Do it when calling $validateSavedOperations()
|
|
||||||
@unchecked ?= {}
|
|
||||||
@unchecked[name] = op
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# After calling this function all not instantiated operations will be accessible.
|
|
||||||
# @see Operation.saveOperation
|
|
||||||
#
|
|
||||||
# @return [Boolean] Whether it was possible to instantiate all operations.
|
|
||||||
#
|
|
||||||
validateSavedOperations: ()->
|
|
||||||
uninstantiated = {}
|
|
||||||
success = @
|
|
||||||
for name, op_uid of @unchecked
|
|
||||||
op = HB.getOperation op_uid
|
|
||||||
if op
|
|
||||||
@[name] = op
|
|
||||||
else
|
|
||||||
uninstantiated[name] = op_uid
|
|
||||||
success = false
|
|
||||||
delete @unchecked
|
|
||||||
if not success
|
|
||||||
@unchecked = uninstantiated
|
|
||||||
success
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# A simple Delete-type operation that deletes an operation.
|
|
||||||
#
|
|
||||||
class types.Delete extends types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Object} deletes UID or reference of the operation that this to be deleted.
|
|
||||||
#
|
|
||||||
constructor: (uid, deletes)->
|
|
||||||
@saveOperation 'deletes', deletes
|
|
||||||
super uid
|
|
||||||
|
|
||||||
type: "Delete"
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Convert all relevant information of this operation to the json-format.
|
|
||||||
# This result can be sent to other clients.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
{
|
|
||||||
'type': "Delete"
|
|
||||||
'uid': @getUid()
|
|
||||||
'deletes': @deletes.getUid()
|
|
||||||
}
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Apply the deletion.
|
|
||||||
#
|
|
||||||
execute: ()->
|
|
||||||
if @validateSavedOperations()
|
|
||||||
res = super
|
|
||||||
if res
|
|
||||||
@deletes.applyDelete @
|
|
||||||
res
|
|
||||||
else
|
|
||||||
false
|
|
||||||
|
|
||||||
#
|
|
||||||
# Define how to parse Delete operations.
|
|
||||||
#
|
|
||||||
types.Delete.parse = (o)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
'deletes': deletes_uid
|
|
||||||
} = o
|
|
||||||
new this(uid, deletes_uid)
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# A simple insert-type operation.
|
|
||||||
#
|
|
||||||
# An insert operation is always positioned between two other insert operations.
|
|
||||||
# Internally this is realized as associative lists, whereby each insert operation has a predecessor and a successor.
|
|
||||||
# For the sake of efficiency we maintain two lists:
|
|
||||||
# - The short-list (abbrev. sl) maintains only the operations that are not deleted
|
|
||||||
# - The complete-list (abbrev. cl) maintains all operations
|
|
||||||
#
|
|
||||||
class types.Insert extends types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Operation} prev_cl The predecessor of this operation in the complete-list (cl)
|
|
||||||
# @param {Operation} next_cl The successor of this operation in the complete-list (cl)
|
|
||||||
#
|
|
||||||
constructor: (uid, prev_cl, next_cl, origin, parent)->
|
|
||||||
@saveOperation 'parent', parent
|
|
||||||
@saveOperation 'prev_cl', prev_cl
|
|
||||||
@saveOperation 'next_cl', next_cl
|
|
||||||
if origin?
|
|
||||||
@saveOperation 'origin', origin
|
|
||||||
else
|
|
||||||
@saveOperation 'origin', prev_cl
|
|
||||||
super uid
|
|
||||||
|
|
||||||
type: "Insert"
|
|
||||||
|
|
||||||
#
|
|
||||||
# set content to null and other stuff
|
|
||||||
# @private
|
|
||||||
#
|
|
||||||
applyDelete: (o)->
|
|
||||||
@deleted_by ?= []
|
|
||||||
callLater = false
|
|
||||||
if @parent? and not @isDeleted() and o? # o? : if not o?, then the delimiter deleted this Insertion. Furthermore, it would be wrong to call it. TODO: make this more expressive and save
|
|
||||||
# call iff wasn't deleted earlyer
|
|
||||||
callLater = true
|
|
||||||
if o?
|
|
||||||
@deleted_by.push o
|
|
||||||
garbagecollect = false
|
|
||||||
if @next_cl.isDeleted()
|
|
||||||
garbagecollect = true
|
|
||||||
super garbagecollect
|
|
||||||
if callLater
|
|
||||||
@callOperationSpecificDeleteEvents(o)
|
|
||||||
if @prev_cl?.isDeleted()
|
|
||||||
# garbage collect prev_cl
|
|
||||||
@prev_cl.applyDelete()
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
if @next_cl.isDeleted()
|
|
||||||
# delete all ops that delete this insertion
|
|
||||||
for d in @deleted_by
|
|
||||||
d.cleanup()
|
|
||||||
|
|
||||||
# throw new Error "right is not deleted. inconsistency!, wrararar"
|
|
||||||
# change origin references to the right
|
|
||||||
o = @next_cl
|
|
||||||
while o.type isnt "Delimiter"
|
|
||||||
if o.origin is @
|
|
||||||
o.origin = @prev_cl
|
|
||||||
o = o.next_cl
|
|
||||||
# reconnect left/right
|
|
||||||
@prev_cl.next_cl = @next_cl
|
|
||||||
@next_cl.prev_cl = @prev_cl
|
|
||||||
super
|
|
||||||
# else
|
|
||||||
# Someone inserted something in the meantime.
|
|
||||||
# Remember: this can only be garbage collected when next_cl is deleted
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# The amount of positions that $this operation was moved to the right.
|
|
||||||
#
|
|
||||||
getDistanceToOrigin: ()->
|
|
||||||
d = 0
|
|
||||||
o = @prev_cl
|
|
||||||
while true
|
|
||||||
if @origin is o
|
|
||||||
break
|
|
||||||
d++
|
|
||||||
o = o.prev_cl
|
|
||||||
d
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Include this operation in the associative lists.
|
|
||||||
execute: ()->
|
|
||||||
if not @validateSavedOperations()
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
if @parent?
|
|
||||||
if not @prev_cl?
|
|
||||||
@prev_cl = @parent.beginning
|
|
||||||
if not @origin?
|
|
||||||
@origin = @parent.beginning
|
|
||||||
if not @next_cl?
|
|
||||||
@next_cl = @parent.end
|
|
||||||
if @prev_cl?
|
|
||||||
distance_to_origin = @getDistanceToOrigin() # most cases: 0
|
|
||||||
o = @prev_cl.next_cl
|
|
||||||
i = distance_to_origin # loop counter
|
|
||||||
|
|
||||||
# $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!)
|
|
||||||
while true
|
|
||||||
if o isnt @next_cl
|
|
||||||
# $o happened concurrently
|
|
||||||
if o.getDistanceToOrigin() is i
|
|
||||||
# case 1
|
|
||||||
if o.uid.creator < @uid.creator
|
|
||||||
@prev_cl = o
|
|
||||||
distance_to_origin = i + 1
|
|
||||||
else
|
|
||||||
# nop
|
|
||||||
else if o.getDistanceToOrigin() < i
|
|
||||||
# case 2
|
|
||||||
if i - distance_to_origin <= o.getDistanceToOrigin()
|
|
||||||
@prev_cl = o
|
|
||||||
distance_to_origin = i + 1
|
|
||||||
else
|
|
||||||
#nop
|
|
||||||
else
|
|
||||||
# case 3
|
|
||||||
break
|
|
||||||
i++
|
|
||||||
o = o.next_cl
|
|
||||||
else
|
|
||||||
# $this knows that $o exists,
|
|
||||||
break
|
|
||||||
# now reconnect everything
|
|
||||||
@next_cl = @prev_cl.next_cl
|
|
||||||
@prev_cl.next_cl = @
|
|
||||||
@next_cl.prev_cl = @
|
|
||||||
|
|
||||||
@setParent @prev_cl.getParent() # do Insertions always have a parent?
|
|
||||||
super # notify the execution_listeners
|
|
||||||
@callOperationSpecificInsertEvents()
|
|
||||||
@
|
|
||||||
|
|
||||||
callOperationSpecificInsertEvents: ()->
|
|
||||||
@parent?.callEvent [
|
|
||||||
type: "insert"
|
|
||||||
position: @getPosition()
|
|
||||||
object: @parent
|
|
||||||
changedBy: @uid.creator
|
|
||||||
value: @content
|
|
||||||
]
|
|
||||||
|
|
||||||
callOperationSpecificDeleteEvents: (o)->
|
|
||||||
@parent.callEvent [
|
|
||||||
type: "delete"
|
|
||||||
position: @getPosition()
|
|
||||||
object: @parent # TODO: You can combine getPosition + getParent in a more efficient manner! (only left Delimiter will hold @parent)
|
|
||||||
length: 1
|
|
||||||
changedBy: o.uid.creator
|
|
||||||
]
|
|
||||||
|
|
||||||
#
|
|
||||||
# Compute the position of this operation.
|
|
||||||
#
|
|
||||||
getPosition: ()->
|
|
||||||
position = 0
|
|
||||||
prev = @prev_cl
|
|
||||||
while true
|
|
||||||
if prev instanceof types.Delimiter
|
|
||||||
break
|
|
||||||
if not prev.isDeleted()
|
|
||||||
position++
|
|
||||||
prev = prev.prev_cl
|
|
||||||
position
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Defines an object that is cannot be changed. You can use this to set an immutable string, or a number.
|
|
||||||
#
|
|
||||||
class types.ImmutableObject extends types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Object} content
|
|
||||||
#
|
|
||||||
constructor: (uid, @content)->
|
|
||||||
super uid
|
|
||||||
|
|
||||||
type: "ImmutableObject"
|
|
||||||
|
|
||||||
#
|
|
||||||
# @return [String] The content of this operation.
|
|
||||||
#
|
|
||||||
val : ()->
|
|
||||||
@content
|
|
||||||
|
|
||||||
#
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json = {
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
'content' : @content
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
types.ImmutableObject.parse = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
'content' : content
|
|
||||||
} = json
|
|
||||||
new this(uid, content)
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# A delimiter is placed at the end and at the beginning of the associative lists.
|
|
||||||
# This is necessary in order to have a beginning and an end even if the content
|
|
||||||
# of the Engine is empty.
|
|
||||||
#
|
|
||||||
class types.Delimiter extends types.Operation
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Operation} prev_cl The predecessor of this operation in the complete-list (cl)
|
|
||||||
# @param {Operation} next_cl The successor of this operation in the complete-list (cl)
|
|
||||||
#
|
|
||||||
constructor: (prev_cl, next_cl, origin)->
|
|
||||||
@saveOperation 'prev_cl', prev_cl
|
|
||||||
@saveOperation 'next_cl', next_cl
|
|
||||||
@saveOperation 'origin', prev_cl
|
|
||||||
super {noOperation: true}
|
|
||||||
|
|
||||||
type: "Delimiter"
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
super()
|
|
||||||
o = @prev_cl
|
|
||||||
while o?
|
|
||||||
o.applyDelete()
|
|
||||||
o = o.prev_cl
|
|
||||||
undefined
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
#
|
|
||||||
execute: ()->
|
|
||||||
if @unchecked?['next_cl']?
|
|
||||||
super
|
|
||||||
else if @unchecked?['prev_cl']
|
|
||||||
if @validateSavedOperations()
|
|
||||||
if @prev_cl.next_cl?
|
|
||||||
throw new Error "Probably duplicated operations"
|
|
||||||
@prev_cl.next_cl = @
|
|
||||||
super
|
|
||||||
else
|
|
||||||
false
|
|
||||||
else if @prev_cl? and not @prev_cl.next_cl?
|
|
||||||
delete @prev_cl.unchecked.next_cl
|
|
||||||
@prev_cl.next_cl = @
|
|
||||||
super
|
|
||||||
else if @prev_cl? or @next_cl? or true # TODO: are you sure? This can happen right?
|
|
||||||
super
|
|
||||||
#else
|
|
||||||
# throw new Error "Delimiter is unsufficient defined!"
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
{
|
|
||||||
'type' : @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
'prev' : @prev_cl?.getUid()
|
|
||||||
'next' : @next_cl?.getUid()
|
|
||||||
}
|
|
||||||
|
|
||||||
types.Delimiter.parse = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
'prev' : prev
|
|
||||||
'next' : next
|
|
||||||
} = json
|
|
||||||
new this(uid, prev, next)
|
|
||||||
|
|
||||||
# This is what this module exports after initializing it with the HistoryBuffer
|
|
||||||
{
|
|
||||||
'types' : types
|
|
||||||
'execution_listener' : execution_listener
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
text_types_uninitialized = require "./TextTypes"
|
|
||||||
|
|
||||||
module.exports = (HB)->
|
|
||||||
text_types = text_types_uninitialized HB
|
|
||||||
types = text_types.types
|
|
||||||
|
|
||||||
#
|
|
||||||
# Manages Object-like values.
|
|
||||||
#
|
|
||||||
class types.Object extends types.MapManager
|
|
||||||
|
|
||||||
#
|
|
||||||
# Identifies this class.
|
|
||||||
# Use it to check whether this is a json-type or something else.
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# var x = y.val('unknown')
|
|
||||||
# if (x.type === "Object") {
|
|
||||||
# console.log JSON.stringify(x.toJson())
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
type: "Object"
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Transform this to a Json. If your browser supports Object.observe it will be transformed automatically when a change arrives.
|
|
||||||
# Otherwise you will loose all the sharing-abilities (the new object will be a deep clone)!
|
|
||||||
# @return {Json}
|
|
||||||
#
|
|
||||||
# TODO: at the moment you don't consider changing of properties.
|
|
||||||
# E.g.: let x = {a:[]}. Then x.a.push 1 wouldn't change anything
|
|
||||||
#
|
|
||||||
toJson: (transform_to_value = false)->
|
|
||||||
if not @bound_json? or not Object.observe? or true # TODO: currently, you are not watching mutable strings for changes, and, therefore, the @bound_json is not updated. TODO TODO wuawuawua easy
|
|
||||||
val = @val()
|
|
||||||
json = {}
|
|
||||||
for name, o of val
|
|
||||||
if o instanceof types.Object
|
|
||||||
json[name] = o.toJson(transform_to_value)
|
|
||||||
else if o instanceof types.Array
|
|
||||||
json[name] = o.toJson(transform_to_value)
|
|
||||||
else if transform_to_value and o instanceof types.Operation
|
|
||||||
json[name] = o.val()
|
|
||||||
else
|
|
||||||
json[name] = o
|
|
||||||
@bound_json = json
|
|
||||||
if Object.observe?
|
|
||||||
that = @
|
|
||||||
Object.observe @bound_json, (events)->
|
|
||||||
for event in events
|
|
||||||
if not event.changedBy? and (event.type is "add" or event.type = "update")
|
|
||||||
# this event is not created by Y.
|
|
||||||
that.val(event.name, event.object[event.name])
|
|
||||||
@observe (events)->
|
|
||||||
for event in events
|
|
||||||
if event.created_ isnt HB.getUserId()
|
|
||||||
notifier = Object.getNotifier(that.bound_json)
|
|
||||||
oldVal = that.bound_json[event.name]
|
|
||||||
if oldVal?
|
|
||||||
notifier.performChange 'update', ()->
|
|
||||||
that.bound_json[event.name] = that.val(event.name)
|
|
||||||
, that.bound_json
|
|
||||||
notifier.notify
|
|
||||||
object: that.bound_json
|
|
||||||
type: 'update'
|
|
||||||
name: event.name
|
|
||||||
oldValue: oldVal
|
|
||||||
changedBy: event.changedBy
|
|
||||||
else
|
|
||||||
notifier.performChange 'add', ()->
|
|
||||||
that.bound_json[event.name] = that.val(event.name)
|
|
||||||
, that.bound_json
|
|
||||||
notifier.notify
|
|
||||||
object: that.bound_json
|
|
||||||
type: 'add'
|
|
||||||
name: event.name
|
|
||||||
oldValue: oldVal
|
|
||||||
changedBy:event.changedBy
|
|
||||||
@bound_json
|
|
||||||
|
|
||||||
#
|
|
||||||
# @overload val()
|
|
||||||
# Get this as a Json object.
|
|
||||||
# @return [Json]
|
|
||||||
#
|
|
||||||
# @overload val(name)
|
|
||||||
# Get value of a property.
|
|
||||||
# @param {String} name Name of the object property.
|
|
||||||
# @return [Object Type||String|Object] Depending on the value of the property. If mutable it will return a Operation-type object, if immutable it will return String/Object.
|
|
||||||
#
|
|
||||||
# @overload val(name, content)
|
|
||||||
# Set a new property.
|
|
||||||
# @param {String} name Name of the object property.
|
|
||||||
# @param {Object|String} content Content of the object property.
|
|
||||||
# @return [Object Type] This object. (supports chaining)
|
|
||||||
#
|
|
||||||
val: (name, content)->
|
|
||||||
if name? and arguments.length > 1
|
|
||||||
if content? and content.constructor?
|
|
||||||
type = types[content.constructor.name]
|
|
||||||
if type? and type.create?
|
|
||||||
args = []
|
|
||||||
for i in [1...arguments.length]
|
|
||||||
args.push arguments[i]
|
|
||||||
o = type.create.apply null, args
|
|
||||||
super name, o
|
|
||||||
else
|
|
||||||
throw new Error "The #{content.constructor.name}-type is not (yet) supported in Y."
|
|
||||||
else
|
|
||||||
super name, content
|
|
||||||
else # is this even necessary ? I have to define every type anyway.. (see Number type below)
|
|
||||||
super name
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
{
|
|
||||||
'type' : @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
}
|
|
||||||
|
|
||||||
types.Object.parse = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
} = json
|
|
||||||
new this(uid)
|
|
||||||
|
|
||||||
types.Object.create = (content, mutable)->
|
|
||||||
json = new types.Object().execute()
|
|
||||||
for n,o of content
|
|
||||||
json.val n, o, mutable
|
|
||||||
json
|
|
||||||
|
|
||||||
|
|
||||||
types.Number = {}
|
|
||||||
types.Number.create = (content)->
|
|
||||||
content
|
|
||||||
|
|
||||||
text_types
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
basic_types_uninitialized = require "./BasicTypes"
|
|
||||||
|
|
||||||
module.exports = (HB)->
|
|
||||||
basic_types = basic_types_uninitialized HB
|
|
||||||
types = basic_types.types
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Manages map like objects. E.g. Json-Type and XML attributes.
|
|
||||||
#
|
|
||||||
class types.MapManager extends types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
#
|
|
||||||
constructor: (uid)->
|
|
||||||
@map = {}
|
|
||||||
super uid
|
|
||||||
|
|
||||||
type: "MapManager"
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
for name,p of @map
|
|
||||||
p.applyDelete()
|
|
||||||
super()
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
#
|
|
||||||
# @see JsonTypes.val
|
|
||||||
#
|
|
||||||
val: (name, content)->
|
|
||||||
if arguments.length > 1
|
|
||||||
@retrieveSub(name).replace content
|
|
||||||
@
|
|
||||||
else if name?
|
|
||||||
prop = @map[name]
|
|
||||||
if prop? and not prop.isContentDeleted()
|
|
||||||
prop.val()
|
|
||||||
else
|
|
||||||
undefined
|
|
||||||
else
|
|
||||||
result = {}
|
|
||||||
for name,o of @map
|
|
||||||
if not o.isContentDeleted()
|
|
||||||
result[name] = o.val()
|
|
||||||
result
|
|
||||||
|
|
||||||
delete: (name)->
|
|
||||||
@map[name]?.deleteContent()
|
|
||||||
@
|
|
||||||
|
|
||||||
retrieveSub: (property_name)->
|
|
||||||
if not @map[property_name]?
|
|
||||||
event_properties =
|
|
||||||
name: property_name
|
|
||||||
event_this = @
|
|
||||||
map_uid = @cloneUid()
|
|
||||||
map_uid.sub = property_name
|
|
||||||
rm_uid =
|
|
||||||
noOperation: true
|
|
||||||
alt: map_uid
|
|
||||||
rm = new types.ReplaceManager event_properties, event_this, rm_uid # this operation shall not be saved in the HB
|
|
||||||
@map[property_name] = rm
|
|
||||||
rm.setParent @, property_name
|
|
||||||
rm.execute()
|
|
||||||
@map[property_name]
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Manages a list of Insert-type operations.
|
|
||||||
#
|
|
||||||
class types.ListManager extends types.Operation
|
|
||||||
|
|
||||||
#
|
|
||||||
# A ListManager maintains a non-empty list that has a beginning and an end (both Delimiters!)
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Delimiter} beginning Reference or Object.
|
|
||||||
# @param {Delimiter} end Reference or Object.
|
|
||||||
constructor: (uid)->
|
|
||||||
@beginning = new types.Delimiter undefined, undefined
|
|
||||||
@end = new types.Delimiter @beginning, undefined
|
|
||||||
@beginning.next_cl = @end
|
|
||||||
@beginning.execute()
|
|
||||||
@end.execute()
|
|
||||||
super uid
|
|
||||||
|
|
||||||
type: "ListManager"
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# @see Operation.execute
|
|
||||||
#
|
|
||||||
execute: ()->
|
|
||||||
if @validateSavedOperations()
|
|
||||||
@beginning.setParent @
|
|
||||||
@end.setParent @
|
|
||||||
super
|
|
||||||
else
|
|
||||||
false
|
|
||||||
|
|
||||||
# Get the element previous to the delemiter at the end
|
|
||||||
getLastOperation: ()->
|
|
||||||
@end.prev_cl
|
|
||||||
|
|
||||||
# similar to the above
|
|
||||||
getFirstOperation: ()->
|
|
||||||
@beginning.next_cl
|
|
||||||
|
|
||||||
# Transforms the the list to an array
|
|
||||||
# Doesn't return left-right delimiter.
|
|
||||||
toArray: ()->
|
|
||||||
o = @beginning.next_cl
|
|
||||||
result = []
|
|
||||||
while o isnt @end
|
|
||||||
result.push o
|
|
||||||
o = o.next_cl
|
|
||||||
result
|
|
||||||
|
|
||||||
#
|
|
||||||
# Retrieves the x-th not deleted element.
|
|
||||||
# e.g. "abc" : the 1th character is "a"
|
|
||||||
# the 0th character is the left Delimiter
|
|
||||||
#
|
|
||||||
getOperationByPosition: (position)->
|
|
||||||
o = @beginning
|
|
||||||
while true
|
|
||||||
# find the i-th op
|
|
||||||
if o instanceof types.Delimiter and o.prev_cl?
|
|
||||||
# the user or you gave a position parameter that is to big
|
|
||||||
# for the current array. Therefore we reach a Delimiter.
|
|
||||||
# Then, we'll just return the last character.
|
|
||||||
o = o.prev_cl
|
|
||||||
while o.isDeleted() or not (o instanceof types.Delimiter)
|
|
||||||
o = o.prev_cl
|
|
||||||
break
|
|
||||||
if position <= 0 and not o.isDeleted()
|
|
||||||
break
|
|
||||||
|
|
||||||
o = o.next_cl
|
|
||||||
if not o.isDeleted()
|
|
||||||
position -= 1
|
|
||||||
o
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Adds support for replace. The ReplaceManager manages Replaceable operations.
|
|
||||||
# Each Replaceable holds a value that is now replaceable.
|
|
||||||
#
|
|
||||||
# The TextType-type has implemented support for replace
|
|
||||||
# @see TextType
|
|
||||||
#
|
|
||||||
class types.ReplaceManager extends types.ListManager
|
|
||||||
#
|
|
||||||
# @param {Object} event_properties Decorates the event that is thrown by the RM
|
|
||||||
# @param {Object} event_this The object on which the event shall be executed
|
|
||||||
# @param {Operation} initial_content Initialize this with a Replaceable that holds the initial_content.
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Delimiter} beginning Reference or Object.
|
|
||||||
# @param {Delimiter} end Reference or Object.
|
|
||||||
constructor: (@event_properties, @event_this, uid, beginning, end)->
|
|
||||||
if not @event_properties['object']?
|
|
||||||
@event_properties['object'] = @event_this
|
|
||||||
super uid, beginning, end
|
|
||||||
|
|
||||||
type: "ReplaceManager"
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
o = @beginning
|
|
||||||
while o?
|
|
||||||
o.applyDelete()
|
|
||||||
o = o.next_cl
|
|
||||||
super()
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
#
|
|
||||||
# This doesn't throw the same events as the ListManager. Therefore, the
|
|
||||||
# Replaceables also not throw the same events.
|
|
||||||
# So, ReplaceManager and ListManager both implement
|
|
||||||
# these functions that are called when an Insertion is executed (at the end).
|
|
||||||
#
|
|
||||||
#
|
|
||||||
callEventDecorator: (events)->
|
|
||||||
if not @isDeleted()
|
|
||||||
for event in events
|
|
||||||
for name,prop of @event_properties
|
|
||||||
event[name] = prop
|
|
||||||
@event_this.callEvent events
|
|
||||||
undefined
|
|
||||||
|
|
||||||
#
|
|
||||||
# Replace the existing word with a new word.
|
|
||||||
#
|
|
||||||
# @param content {Operation} The new value of this ReplaceManager.
|
|
||||||
# @param replaceable_uid {UID} Optional: Unique id of the Replaceable that is created
|
|
||||||
#
|
|
||||||
replace: (content, replaceable_uid)->
|
|
||||||
o = @getLastOperation()
|
|
||||||
relp = (new types.Replaceable content, @, replaceable_uid, o, o.next_cl).execute()
|
|
||||||
# TODO: delete repl (for debugging)
|
|
||||||
undefined
|
|
||||||
|
|
||||||
isContentDeleted: ()->
|
|
||||||
@getLastOperation().isDeleted()
|
|
||||||
|
|
||||||
deleteContent: ()->
|
|
||||||
(new types.Delete undefined, @getLastOperation().uid).execute()
|
|
||||||
undefined
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the value of this
|
|
||||||
# @return {String}
|
|
||||||
#
|
|
||||||
val: ()->
|
|
||||||
o = @getLastOperation()
|
|
||||||
#if o instanceof types.Delimiter
|
|
||||||
# throw new Error "Replace Manager doesn't contain anything."
|
|
||||||
o.val?() # ? - for the case that (currently) the RM does not contain anything (then o is a Delimiter)
|
|
||||||
|
|
||||||
#
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json =
|
|
||||||
{
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
'beginning' : @beginning.getUid()
|
|
||||||
'end' : @end.getUid()
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# The ReplaceManager manages Replaceables.
|
|
||||||
# @see ReplaceManager
|
|
||||||
#
|
|
||||||
class types.Replaceable extends types.Insert
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Operation} content The value that this Replaceable holds.
|
|
||||||
# @param {ReplaceManager} parent Used to replace this Replaceable with another one.
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
#
|
|
||||||
constructor: (content, parent, uid, prev, next, origin, is_deleted)->
|
|
||||||
# see encode to see, why we are doing it this way
|
|
||||||
if content? and content.creator?
|
|
||||||
@saveOperation 'content', content
|
|
||||||
else
|
|
||||||
@content = content
|
|
||||||
@saveOperation 'parent', parent
|
|
||||||
super uid, prev, next, origin # Parent is already saved by Replaceable
|
|
||||||
@is_deleted = is_deleted
|
|
||||||
|
|
||||||
type: "Replaceable"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Return the content that this operation holds.
|
|
||||||
#
|
|
||||||
val: ()->
|
|
||||||
@content
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
res = super
|
|
||||||
if @content?
|
|
||||||
if @next_cl.type isnt "Delimiter"
|
|
||||||
@content.deleteAllObservers?()
|
|
||||||
@content.applyDelete?()
|
|
||||||
@content.dontSync?()
|
|
||||||
@content = null
|
|
||||||
res
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super
|
|
||||||
|
|
||||||
#
|
|
||||||
# This is called, when the Insert-type was successfully executed.
|
|
||||||
# TODO: consider doing this in a more consistent manner. This could also be
|
|
||||||
# done with execute. But currently, there are no specital Insert-types for ListManager.
|
|
||||||
#
|
|
||||||
callOperationSpecificInsertEvents: ()->
|
|
||||||
if @next_cl.type is "Delimiter" and @prev_cl.type isnt "Delimiter"
|
|
||||||
# this replaces another Replaceable
|
|
||||||
if not @is_deleted # When this is received from the HB, this could already be deleted!
|
|
||||||
old_value = @prev_cl.content
|
|
||||||
@parent.callEventDecorator [
|
|
||||||
type: "update"
|
|
||||||
changedBy: @uid.creator
|
|
||||||
oldValue: old_value
|
|
||||||
]
|
|
||||||
@prev_cl.applyDelete()
|
|
||||||
else if @next_cl.type isnt "Delimiter"
|
|
||||||
# This won't be recognized by the user, because another
|
|
||||||
# concurrent operation is set as the current value of the RM
|
|
||||||
@applyDelete()
|
|
||||||
else # prev _and_ next are Delimiters. This is the first created Replaceable in the RM
|
|
||||||
@parent.callEventDecorator [
|
|
||||||
type: "add"
|
|
||||||
changedBy: @uid.creator
|
|
||||||
]
|
|
||||||
undefined
|
|
||||||
|
|
||||||
callOperationSpecificDeleteEvents: (o)->
|
|
||||||
if @next_cl.type is "Delimiter"
|
|
||||||
@parent.callEventDecorator [
|
|
||||||
type: "delete"
|
|
||||||
changedBy: o.uid.creator
|
|
||||||
oldValue: @content
|
|
||||||
]
|
|
||||||
|
|
||||||
#
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json =
|
|
||||||
{
|
|
||||||
'type': @type
|
|
||||||
'parent' : @parent.getUid()
|
|
||||||
'prev': @prev_cl.getUid()
|
|
||||||
'next': @next_cl.getUid()
|
|
||||||
'origin' : @origin.getUid()
|
|
||||||
'uid' : @getUid()
|
|
||||||
'is_deleted': @is_deleted
|
|
||||||
}
|
|
||||||
if @content instanceof types.Operation
|
|
||||||
json['content'] = @content.getUid()
|
|
||||||
else
|
|
||||||
# This could be a security concern.
|
|
||||||
# Throw error if the users wants to trick us
|
|
||||||
if @content? and @content.creator?
|
|
||||||
throw new Error "You must not set creator here!"
|
|
||||||
json['content'] = @content
|
|
||||||
json
|
|
||||||
|
|
||||||
types.Replaceable.parse = (json)->
|
|
||||||
{
|
|
||||||
'content' : content
|
|
||||||
'parent' : parent
|
|
||||||
'uid' : uid
|
|
||||||
'prev': prev
|
|
||||||
'next': next
|
|
||||||
'origin' : origin
|
|
||||||
'is_deleted': is_deleted
|
|
||||||
} = json
|
|
||||||
new this(content, parent, uid, prev, next, origin, is_deleted)
|
|
||||||
|
|
||||||
|
|
||||||
basic_types
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
structured_types_uninitialized = require "./StructuredTypes"
|
|
||||||
|
|
||||||
module.exports = (HB)->
|
|
||||||
structured_types = structured_types_uninitialized HB
|
|
||||||
types = structured_types.types
|
|
||||||
parser = structured_types.parser
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Extends the basic Insert type to an operation that holds a text value
|
|
||||||
#
|
|
||||||
class types.TextInsert extends types.Insert
|
|
||||||
#
|
|
||||||
# @param {String} content The content of this Insert-type Operation. Usually you restrict the length of content to size 1
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
#
|
|
||||||
constructor: (content, uid, prev, next, origin, parent)->
|
|
||||||
if content?.creator
|
|
||||||
@saveOperation 'content', content
|
|
||||||
else
|
|
||||||
@content = content
|
|
||||||
super uid, prev, next, origin, parent
|
|
||||||
|
|
||||||
type: "TextInsert"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Retrieve the effective length of the $content of this operation.
|
|
||||||
#
|
|
||||||
getLength: ()->
|
|
||||||
if @isDeleted()
|
|
||||||
0
|
|
||||||
else
|
|
||||||
@content.length
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
super # no braces indeed!
|
|
||||||
if @content instanceof types.Operation
|
|
||||||
@content.applyDelete()
|
|
||||||
@content = null
|
|
||||||
|
|
||||||
execute: ()->
|
|
||||||
if not @validateSavedOperations()
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
if @content instanceof types.Operation
|
|
||||||
@content.insert_parent = @
|
|
||||||
super()
|
|
||||||
|
|
||||||
#
|
|
||||||
# The result will be concatenated with the results from the other insert operations
|
|
||||||
# in order to retrieve the content of the engine.
|
|
||||||
# @see HistoryBuffer.toExecutedArray
|
|
||||||
#
|
|
||||||
val: (current_position)->
|
|
||||||
if @isDeleted() or not @content?
|
|
||||||
""
|
|
||||||
else
|
|
||||||
@content
|
|
||||||
|
|
||||||
#
|
|
||||||
# Convert all relevant information of this operation to the json-format.
|
|
||||||
# This result can be send to other clients.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json =
|
|
||||||
{
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
'prev': @prev_cl.getUid()
|
|
||||||
'next': @next_cl.getUid()
|
|
||||||
'origin': @origin.getUid()
|
|
||||||
'parent': @parent.getUid()
|
|
||||||
}
|
|
||||||
|
|
||||||
if @content?.getUid?
|
|
||||||
json['content'] = @content.getUid()
|
|
||||||
else
|
|
||||||
json['content'] = @content
|
|
||||||
json
|
|
||||||
|
|
||||||
types.TextInsert.parse = (json)->
|
|
||||||
{
|
|
||||||
'content' : content
|
|
||||||
'uid' : uid
|
|
||||||
'prev': prev
|
|
||||||
'next': next
|
|
||||||
'origin' : origin
|
|
||||||
'parent' : parent
|
|
||||||
} = json
|
|
||||||
new types.TextInsert content, uid, prev, next, origin, parent
|
|
||||||
|
|
||||||
|
|
||||||
class types.Array extends types.ListManager
|
|
||||||
|
|
||||||
type: "Array"
|
|
||||||
|
|
||||||
applyDelete: ()->
|
|
||||||
o = @end
|
|
||||||
while o?
|
|
||||||
o.applyDelete()
|
|
||||||
o = o.prev_cl
|
|
||||||
super()
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
toJson: (transform_to_value = false)->
|
|
||||||
val = @val()
|
|
||||||
for i, o in val
|
|
||||||
if o instanceof types.Object
|
|
||||||
o.toJson(transform_to_value)
|
|
||||||
else if o instanceof types.Array
|
|
||||||
o.toJson(transform_to_value)
|
|
||||||
else if transform_to_value and o instanceof types.Operation
|
|
||||||
o.val()
|
|
||||||
else
|
|
||||||
o
|
|
||||||
|
|
||||||
val: (pos)->
|
|
||||||
if pos?
|
|
||||||
o = @getOperationByPosition(pos+1)
|
|
||||||
if not (o instanceof types.Delimiter)
|
|
||||||
o.val()
|
|
||||||
else
|
|
||||||
throw new Error "this position does not exist"
|
|
||||||
else
|
|
||||||
o = @beginning.next_cl
|
|
||||||
result = []
|
|
||||||
while o isnt @end
|
|
||||||
if not o.isDeleted()
|
|
||||||
result.push o.val()
|
|
||||||
o = o.next_cl
|
|
||||||
result
|
|
||||||
|
|
||||||
push: (content)->
|
|
||||||
@insertAfter @end.prev_cl, content
|
|
||||||
|
|
||||||
insertAfter: (left, content, options)->
|
|
||||||
createContent = (content, options)->
|
|
||||||
if content? and content.constructor?
|
|
||||||
type = types[content.constructor.name]
|
|
||||||
if type? and type.create?
|
|
||||||
type.create content, options
|
|
||||||
else
|
|
||||||
throw new Error "The #{content.constructor.name}-type is not (yet) supported in Y."
|
|
||||||
else
|
|
||||||
content
|
|
||||||
|
|
||||||
right = left.next_cl
|
|
||||||
while right.isDeleted()
|
|
||||||
right = right.next_cl # find the first character to the right, that is not deleted. In the case that position is 0, its the Delimiter.
|
|
||||||
left = right.prev_cl
|
|
||||||
|
|
||||||
if content instanceof types.Operation
|
|
||||||
(new types.TextInsert content, undefined, left, right).execute()
|
|
||||||
else
|
|
||||||
for c in content
|
|
||||||
tmp = (new types.TextInsert createContent(c, options), undefined, left, right).execute()
|
|
||||||
left = tmp
|
|
||||||
@
|
|
||||||
|
|
||||||
#
|
|
||||||
# Inserts a string into the word.
|
|
||||||
#
|
|
||||||
# @return {Array Type} This String object.
|
|
||||||
#
|
|
||||||
insert: (position, content, options)->
|
|
||||||
ith = @getOperationByPosition position
|
|
||||||
# the (i-1)th character. e.g. "abc" the 1th character is "a"
|
|
||||||
# the 0th character is the left Delimiter
|
|
||||||
@insertAfter ith, [content], options
|
|
||||||
|
|
||||||
#
|
|
||||||
# Deletes a part of the word.
|
|
||||||
#
|
|
||||||
# @return {Array Type} This String object
|
|
||||||
#
|
|
||||||
delete: (position, length)->
|
|
||||||
o = @getOperationByPosition(position+1) # position 0 in this case is the deletion of the first character
|
|
||||||
|
|
||||||
delete_ops = []
|
|
||||||
for i in [0...length]
|
|
||||||
if o instanceof types.Delimiter
|
|
||||||
break
|
|
||||||
d = (new types.Delete undefined, o).execute()
|
|
||||||
o = o.next_cl
|
|
||||||
while (not (o instanceof types.Delimiter)) and o.isDeleted()
|
|
||||||
o = o.next_cl
|
|
||||||
delete_ops.push d._encode()
|
|
||||||
@
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json = {
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
types.Array.parse = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
} = json
|
|
||||||
new this(uid)
|
|
||||||
|
|
||||||
types.Array.create = (content, mutable)->
|
|
||||||
if (mutable is "mutable")
|
|
||||||
list = new types.Array().execute()
|
|
||||||
ith = list.getOperationByPosition 0
|
|
||||||
list.insertAfter ith, content
|
|
||||||
list
|
|
||||||
else if (not mutable?) or (mutable is "immutable")
|
|
||||||
content
|
|
||||||
else
|
|
||||||
throw new Error "Specify either \"mutable\" or \"immutable\"!!"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Handles a String-like data structures with support for insert/delete at a word-position.
|
|
||||||
# @note Currently, only Text is supported!
|
|
||||||
#
|
|
||||||
class types.String extends types.Array
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
#
|
|
||||||
constructor: (uid)->
|
|
||||||
@textfields = []
|
|
||||||
super uid
|
|
||||||
|
|
||||||
#
|
|
||||||
# Identifies this class.
|
|
||||||
# Use it to check whether this is a word-type or something else.
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# var x = y.val('unknown')
|
|
||||||
# if (x.type === "String") {
|
|
||||||
# console.log JSON.stringify(x.toJson())
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
type: "String"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the String-representation of this word.
|
|
||||||
# @return {String} The String-representation of this object.
|
|
||||||
#
|
|
||||||
val: ()->
|
|
||||||
c = for o in @toArray()
|
|
||||||
if o.val?
|
|
||||||
o.val()
|
|
||||||
else
|
|
||||||
""
|
|
||||||
c.join('')
|
|
||||||
|
|
||||||
#
|
|
||||||
# Same as String.val
|
|
||||||
# @see String.val
|
|
||||||
#
|
|
||||||
toString: ()->
|
|
||||||
@val()
|
|
||||||
|
|
||||||
#
|
|
||||||
# Inserts a string into the word.
|
|
||||||
#
|
|
||||||
# @return {Array Type} This String object.
|
|
||||||
#
|
|
||||||
insert: (position, content, options)->
|
|
||||||
ith = @getOperationByPosition position
|
|
||||||
# the (i-1)th character. e.g. "abc" the 1th character is "a"
|
|
||||||
# the 0th character is the left Delimiter
|
|
||||||
@insertAfter ith, content, options
|
|
||||||
|
|
||||||
#
|
|
||||||
# Bind this String to a textfield or input field.
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# var textbox = document.getElementById("textfield");
|
|
||||||
# y.bind(textbox);
|
|
||||||
#
|
|
||||||
bind: (textfield, dom_root)->
|
|
||||||
dom_root ?= window
|
|
||||||
if (not dom_root.getSelection?)
|
|
||||||
dom_root = window
|
|
||||||
|
|
||||||
# don't duplicate!
|
|
||||||
for t in @textfields
|
|
||||||
if t is textfield
|
|
||||||
return
|
|
||||||
creator_token = false;
|
|
||||||
|
|
||||||
word = @
|
|
||||||
textfield.value = @val()
|
|
||||||
@textfields.push textfield
|
|
||||||
|
|
||||||
if textfield.selectionStart? and textfield.setSelectionRange?
|
|
||||||
createRange = (fix)->
|
|
||||||
left = textfield.selectionStart
|
|
||||||
right = textfield.selectionEnd
|
|
||||||
if fix?
|
|
||||||
left = fix left
|
|
||||||
right = fix right
|
|
||||||
{
|
|
||||||
left: left
|
|
||||||
right: right
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRange = (range)->
|
|
||||||
writeContent word.val()
|
|
||||||
textfield.setSelectionRange range.left, range.right
|
|
||||||
|
|
||||||
writeContent = (content)->
|
|
||||||
textfield.value = content
|
|
||||||
else
|
|
||||||
createRange = (fix)->
|
|
||||||
s = dom_root.getSelection()
|
|
||||||
clength = textfield.textContent.length
|
|
||||||
left = Math.min s.anchorOffset, clength
|
|
||||||
right = Math.min s.focusOffset, clength
|
|
||||||
if fix?
|
|
||||||
left = fix left
|
|
||||||
right = fix right
|
|
||||||
{
|
|
||||||
left: left
|
|
||||||
right: right
|
|
||||||
isReal: true
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRange = (range)->
|
|
||||||
writeContent word.val()
|
|
||||||
textnode = textfield.childNodes[0]
|
|
||||||
if range.isReal and textnode?
|
|
||||||
if range.left < 0
|
|
||||||
range.left = 0
|
|
||||||
range.right = Math.max range.left, range.right
|
|
||||||
if range.right > textnode.length
|
|
||||||
range.right = textnode.length
|
|
||||||
range.left = Math.min range.left, range.right
|
|
||||||
r = document.createRange()
|
|
||||||
r.setStart(textnode, range.left)
|
|
||||||
r.setEnd(textnode, range.right)
|
|
||||||
s = window.getSelection()
|
|
||||||
s.removeAllRanges()
|
|
||||||
s.addRange(r)
|
|
||||||
writeContent = (content)->
|
|
||||||
append = ""
|
|
||||||
if content[content.length - 1] is " "
|
|
||||||
content = content.slice(0,content.length-1)
|
|
||||||
append = ' '
|
|
||||||
textfield.textContent = content
|
|
||||||
textfield.innerHTML += append
|
|
||||||
|
|
||||||
writeContent this.val()
|
|
||||||
|
|
||||||
@observe (events)->
|
|
||||||
for event in events
|
|
||||||
if not creator_token
|
|
||||||
if event.type is "insert"
|
|
||||||
o_pos = event.position
|
|
||||||
fix = (cursor)->
|
|
||||||
if cursor <= o_pos
|
|
||||||
cursor
|
|
||||||
else
|
|
||||||
cursor += 1
|
|
||||||
cursor
|
|
||||||
r = createRange fix
|
|
||||||
writeRange r
|
|
||||||
|
|
||||||
else if event.type is "delete"
|
|
||||||
o_pos = event.position
|
|
||||||
fix = (cursor)->
|
|
||||||
if cursor < o_pos
|
|
||||||
cursor
|
|
||||||
else
|
|
||||||
cursor -= 1
|
|
||||||
cursor
|
|
||||||
r = createRange fix
|
|
||||||
writeRange r
|
|
||||||
|
|
||||||
# consume all text-insert changes.
|
|
||||||
textfield.onkeypress = (event)->
|
|
||||||
if word.is_deleted
|
|
||||||
# if word is deleted, do not do anything ever again
|
|
||||||
textfield.onkeypress = null
|
|
||||||
return true
|
|
||||||
creator_token = true
|
|
||||||
char = null
|
|
||||||
if event.key?
|
|
||||||
if event.charCode is 32
|
|
||||||
char = " "
|
|
||||||
else if event.keyCode is 13
|
|
||||||
char = '\n'
|
|
||||||
else
|
|
||||||
char = event.key
|
|
||||||
else
|
|
||||||
char = window.String.fromCharCode event.keyCode
|
|
||||||
if char.length > 1
|
|
||||||
return true
|
|
||||||
else if char.length > 0
|
|
||||||
r = createRange()
|
|
||||||
pos = Math.min r.left, r.right
|
|
||||||
diff = Math.abs(r.right - r.left)
|
|
||||||
word.delete pos, diff
|
|
||||||
word.insert pos, char
|
|
||||||
r.left = pos + char.length
|
|
||||||
r.right = r.left
|
|
||||||
writeRange r
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
creator_token = false
|
|
||||||
false
|
|
||||||
|
|
||||||
textfield.onpaste = (event)->
|
|
||||||
if word.is_deleted
|
|
||||||
# if word is deleted, do not do anything ever again
|
|
||||||
textfield.onpaste = null
|
|
||||||
return true
|
|
||||||
event.preventDefault()
|
|
||||||
textfield.oncut = (event)->
|
|
||||||
if word.is_deleted
|
|
||||||
# if word is deleted, do not do anything ever again
|
|
||||||
textfield.oncut = null
|
|
||||||
return true
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
#
|
|
||||||
# consume deletes. Note that
|
|
||||||
# chrome: won't consume deletions on keypress event.
|
|
||||||
# keyCode is deprecated. BUT: I don't see another way.
|
|
||||||
# since event.key is not implemented in the current version of chrome.
|
|
||||||
# Every browser supports keyCode. Let's stick with it for now..
|
|
||||||
#
|
|
||||||
textfield.onkeydown = (event)->
|
|
||||||
creator_token = true
|
|
||||||
if word.is_deleted
|
|
||||||
# if word is deleted, do not do anything ever again
|
|
||||||
textfield.onkeydown = null
|
|
||||||
return true
|
|
||||||
r = createRange()
|
|
||||||
pos = Math.min(r.left, r.right, word.val().length)
|
|
||||||
diff = Math.abs(r.left - r.right)
|
|
||||||
if event.keyCode? and event.keyCode is 8 # Backspace
|
|
||||||
if diff > 0
|
|
||||||
word.delete pos, diff
|
|
||||||
r.left = pos
|
|
||||||
r.right = pos
|
|
||||||
writeRange r
|
|
||||||
else
|
|
||||||
if event.ctrlKey? and event.ctrlKey
|
|
||||||
val = word.val()
|
|
||||||
new_pos = pos
|
|
||||||
del_length = 0
|
|
||||||
if pos > 0
|
|
||||||
new_pos--
|
|
||||||
del_length++
|
|
||||||
while new_pos > 0 and val[new_pos] isnt " " and val[new_pos] isnt '\n'
|
|
||||||
new_pos--
|
|
||||||
del_length++
|
|
||||||
word.delete new_pos, (pos-new_pos)
|
|
||||||
r.left = new_pos
|
|
||||||
r.right = new_pos
|
|
||||||
writeRange r
|
|
||||||
else
|
|
||||||
if pos > 0
|
|
||||||
word.delete (pos-1), 1
|
|
||||||
r.left = pos-1
|
|
||||||
r.right = pos-1
|
|
||||||
writeRange r
|
|
||||||
event.preventDefault()
|
|
||||||
creator_token = false
|
|
||||||
return false
|
|
||||||
else if event.keyCode? and event.keyCode is 46 # Delete
|
|
||||||
if diff > 0
|
|
||||||
word.delete pos, diff
|
|
||||||
r.left = pos
|
|
||||||
r.right = pos
|
|
||||||
writeRange r
|
|
||||||
else
|
|
||||||
word.delete pos, 1
|
|
||||||
r.left = pos
|
|
||||||
r.right = pos
|
|
||||||
writeRange r
|
|
||||||
event.preventDefault()
|
|
||||||
creator_token = false
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
creator_token = false
|
|
||||||
true
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json = {
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
types.String.parse = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
} = json
|
|
||||||
new this(uid)
|
|
||||||
|
|
||||||
types.String.create = (content, mutable)->
|
|
||||||
if (mutable is "mutable")
|
|
||||||
word = new types.String().execute()
|
|
||||||
word.insert 0, content
|
|
||||||
word
|
|
||||||
else if (not mutable?) or (mutable is "immutable")
|
|
||||||
content
|
|
||||||
else
|
|
||||||
throw new Error "Specify either \"mutable\" or \"immutable\"!!"
|
|
||||||
|
|
||||||
|
|
||||||
structured_types
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
###
|
|
||||||
json_types_uninitialized = require "./JsonTypes"
|
|
||||||
|
|
||||||
# some dom implementations may call another dom.method that simulates the behavior of another.
|
|
||||||
# For example xml.insertChild(dom) , wich inserts an element at the end, and xml.insertAfter(dom,null) wich does the same
|
|
||||||
# But Y's proxy may be called only once!
|
|
||||||
proxy_token = false
|
|
||||||
dont_proxy = (f)->
|
|
||||||
proxy_token = true
|
|
||||||
try
|
|
||||||
f()
|
|
||||||
catch e
|
|
||||||
proxy_token = false
|
|
||||||
throw new Error e
|
|
||||||
proxy_token = false
|
|
||||||
|
|
||||||
_proxy = (f_name, f)->
|
|
||||||
old_f = @[f_name]
|
|
||||||
if old_f?
|
|
||||||
@[f_name] = ()->
|
|
||||||
if not proxy_token and not @_y?.isDeleted()
|
|
||||||
that = this
|
|
||||||
args = arguments
|
|
||||||
dont_proxy ()->
|
|
||||||
f.apply that, args
|
|
||||||
old_f.apply that, args
|
|
||||||
else
|
|
||||||
old_f.apply this, arguments
|
|
||||||
#else
|
|
||||||
# @[f_name] = f
|
|
||||||
Element?.prototype._proxy = _proxy
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = (HB)->
|
|
||||||
json_types = json_types_uninitialized HB
|
|
||||||
types = json_types.types
|
|
||||||
parser = json_types.parser
|
|
||||||
|
|
||||||
#
|
|
||||||
# Manages XML types
|
|
||||||
# Not supported:
|
|
||||||
# * Attribute nodes
|
|
||||||
# * Real replace of child elements (to much overhead). Currently, the new element is inserted after the 'replaced' element, and then it is deleted.
|
|
||||||
# * Namespaces (*NS)
|
|
||||||
# * Browser specific methods (webkit-* operations)
|
|
||||||
class XmlType extends types.Insert
|
|
||||||
|
|
||||||
constructor: (uid, @tagname, attributes, elements, @xml)->
|
|
||||||
### In case you make this instanceof Insert again
|
|
||||||
if prev? and (not next?) and prev.type?
|
|
||||||
# adjust what you actually mean. you want to insert after prev, then
|
|
||||||
# next is not defined. but we only insert after non-deleted elements.
|
|
||||||
# This is also handled in TextInsert.
|
|
||||||
while prev.isDeleted()
|
|
||||||
prev = prev.prev_cl
|
|
||||||
next = prev.next_cl
|
|
||||||
###
|
|
||||||
|
|
||||||
super(uid)
|
|
||||||
|
|
||||||
|
|
||||||
if @xml?._y?
|
|
||||||
d = new types.Delete undefined, @xml._y
|
|
||||||
HB.addOperation(d).execute()
|
|
||||||
@xml._y = null
|
|
||||||
|
|
||||||
if attributes? and elements?
|
|
||||||
@saveOperation 'attributes', attributes
|
|
||||||
@saveOperation 'elements', elements
|
|
||||||
else if (not attributes?) and (not elements?)
|
|
||||||
@attributes = new types.JsonType()
|
|
||||||
@attributes.setMutableDefault 'immutable'
|
|
||||||
HB.addOperation(@attributes).execute()
|
|
||||||
@elements = new types.WordType()
|
|
||||||
@elements.parent = @
|
|
||||||
HB.addOperation(@elements).execute()
|
|
||||||
else
|
|
||||||
throw new Error "Either define attribute and elements both, or none of them"
|
|
||||||
|
|
||||||
if @xml?
|
|
||||||
@tagname = @xml.tagName
|
|
||||||
for i in [0...@xml.attributes.length]
|
|
||||||
attr = xml.attributes[i]
|
|
||||||
@attributes.val(attr.name, attr.value)
|
|
||||||
for n in @xml.childNodes
|
|
||||||
if n.nodeType is n.TEXT_NODE
|
|
||||||
word = new TextNodeType(undefined, n)
|
|
||||||
HB.addOperation(word).execute()
|
|
||||||
@elements.push word
|
|
||||||
else if n.nodeType is n.ELEMENT_NODE
|
|
||||||
element = new XmlType undefined, undefined, undefined, undefined, n
|
|
||||||
HB.addOperation(element).execute()
|
|
||||||
@elements.push element
|
|
||||||
else
|
|
||||||
throw new Error "I don't know Node-type #{n.nodeType}!!"
|
|
||||||
@setXmlProxy()
|
|
||||||
undefined
|
|
||||||
|
|
||||||
#
|
|
||||||
# Identifies this class.
|
|
||||||
# Use it in order to check whether this is an xml-type or something else.
|
|
||||||
#
|
|
||||||
type: "XmlType"
|
|
||||||
|
|
||||||
applyDelete: (op)->
|
|
||||||
if @insert_parent? and not @insert_parent.isDeleted()
|
|
||||||
@insert_parent.applyDelete op
|
|
||||||
else
|
|
||||||
@attributes.applyDelete()
|
|
||||||
@elements.applyDelete()
|
|
||||||
super
|
|
||||||
|
|
||||||
cleanup: ()->
|
|
||||||
super()
|
|
||||||
|
|
||||||
setXmlProxy: ()->
|
|
||||||
@xml._y = @
|
|
||||||
that = @
|
|
||||||
|
|
||||||
@elements.on 'insert', (event, op)->
|
|
||||||
if op.creator isnt HB.getUserId() and this is that.elements
|
|
||||||
newNode = op.content.val()
|
|
||||||
right = op.next_cl
|
|
||||||
while right? and right.isDeleted()
|
|
||||||
right = right.next_cl
|
|
||||||
rightNode = null
|
|
||||||
if right.type isnt 'Delimiter'
|
|
||||||
rightNode = right.val().val()
|
|
||||||
dont_proxy ()->
|
|
||||||
that.xml.insertBefore newNode, rightNode
|
|
||||||
@elements.on 'delete', (event, op)->
|
|
||||||
del_op = op.deleted_by[0]
|
|
||||||
if del_op? and del_op.creator isnt HB.getUserId() and this is that.elements
|
|
||||||
deleted = op.content.val()
|
|
||||||
dont_proxy ()->
|
|
||||||
that.xml.removeChild deleted
|
|
||||||
|
|
||||||
@attributes.on ['add', 'update'], (event, property_name, op)->
|
|
||||||
if op.creator isnt HB.getUserId() and this is that.attributes
|
|
||||||
dont_proxy ()->
|
|
||||||
newval = op.val().val()
|
|
||||||
if newval?
|
|
||||||
that.xml.setAttribute(property_name, op.val().val())
|
|
||||||
else
|
|
||||||
that.xml.removeAttribute(property_name)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Here are all methods that proxy the behavior of the xml
|
|
||||||
|
|
||||||
# you want to find a specific child element. Since they are carried by an Insert-Type, you want to find that Insert-Operation.
|
|
||||||
# @param child {DomElement} Dom element.
|
|
||||||
# @return {InsertType} This carries the XmlType that represents the DomElement (child). false if i couldn't find it.
|
|
||||||
#
|
|
||||||
findNode = (child)->
|
|
||||||
if not child?
|
|
||||||
throw new Error "you must specify a parameter!"
|
|
||||||
child = child._y
|
|
||||||
elem = that.elements.beginning.next_cl
|
|
||||||
while elem.type isnt 'Delimiter' and elem.content isnt child
|
|
||||||
elem = elem.next_cl
|
|
||||||
if elem.type is 'Delimiter'
|
|
||||||
false
|
|
||||||
else
|
|
||||||
elem
|
|
||||||
|
|
||||||
insertBefore = (insertedNode_s, adjacentNode)->
|
|
||||||
next = null
|
|
||||||
if adjacentNode?
|
|
||||||
next = findNode adjacentNode
|
|
||||||
prev = null
|
|
||||||
if next
|
|
||||||
prev = next.prev_cl
|
|
||||||
else
|
|
||||||
prev = @_y.elements.end.prev_cl
|
|
||||||
while prev.isDeleted()
|
|
||||||
prev = prev.prev_cl
|
|
||||||
inserted_nodes = null
|
|
||||||
if insertedNode_s.nodeType is insertedNode_s.DOCUMENT_FRAGMENT_NODE
|
|
||||||
child = insertedNode_s.lastChild
|
|
||||||
while child?
|
|
||||||
element = new XmlType undefined, undefined, undefined, undefined, child
|
|
||||||
HB.addOperation(element).execute()
|
|
||||||
that.elements.insertAfter prev, element
|
|
||||||
child = child.previousSibling
|
|
||||||
else
|
|
||||||
element = new XmlType undefined, undefined, undefined, undefined, insertedNode_s
|
|
||||||
HB.addOperation(element).execute()
|
|
||||||
that.elements.insertAfter prev, element
|
|
||||||
|
|
||||||
@xml._proxy 'insertBefore', insertBefore
|
|
||||||
@xml._proxy 'appendChild', insertBefore
|
|
||||||
@xml._proxy 'removeAttribute', (name)->
|
|
||||||
that.attributes.val(name, undefined)
|
|
||||||
@xml._proxy 'setAttribute', (name, value)->
|
|
||||||
that.attributes.val name, value
|
|
||||||
|
|
||||||
renewClassList = (newclass)->
|
|
||||||
dont_do_it = false
|
|
||||||
if newclass?
|
|
||||||
for elem in this
|
|
||||||
if newclass is elem
|
|
||||||
dont_do_it = true
|
|
||||||
value = Array.prototype.join.call this, " "
|
|
||||||
if newclass? and not dont_do_it
|
|
||||||
value += " "+newclass
|
|
||||||
that.attributes.val('class', value )
|
|
||||||
_proxy.call @xml.classList, 'add', renewClassList
|
|
||||||
_proxy.call @xml.classList, 'remove', renewClassList
|
|
||||||
@xml.__defineSetter__ 'className', (val)->
|
|
||||||
@setAttribute('class', val)
|
|
||||||
@xml.__defineGetter__ 'className', ()->
|
|
||||||
that.attributes.val('class')
|
|
||||||
@xml.__defineSetter__ 'textContent', (val)->
|
|
||||||
# remove all nodes
|
|
||||||
elem = that.xml.firstChild
|
|
||||||
while elem?
|
|
||||||
remove = elem
|
|
||||||
elem = elem.nextSibling
|
|
||||||
that.xml.removeChild remove
|
|
||||||
|
|
||||||
# insert word content
|
|
||||||
if val isnt ""
|
|
||||||
text_node = document.createTextNode val
|
|
||||||
that.xml.appendChild text_node
|
|
||||||
|
|
||||||
removeChild = (node)->
|
|
||||||
elem = findNode node
|
|
||||||
if not elem
|
|
||||||
throw new Error "You are only allowed to delete existing (direct) child elements!"
|
|
||||||
d = new types.Delete undefined, elem
|
|
||||||
HB.addOperation(d).execute()
|
|
||||||
node._y = null
|
|
||||||
@xml._proxy 'removeChild', removeChild
|
|
||||||
@xml._proxy 'replaceChild', (insertedNode, replacedNode)->
|
|
||||||
insertBefore.call this, insertedNode, replacedNode
|
|
||||||
removeChild.call this, replacedNode
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val: (enforce = false)->
|
|
||||||
if document?
|
|
||||||
if (not @xml?) or enforce
|
|
||||||
@xml = document.createElement @tagname
|
|
||||||
|
|
||||||
attr = @attributes.val()
|
|
||||||
for attr_name, value of attr
|
|
||||||
if value?
|
|
||||||
a = document.createAttribute attr_name
|
|
||||||
a.value = value
|
|
||||||
@xml.setAttributeNode a
|
|
||||||
|
|
||||||
e = @elements.beginning.next_cl
|
|
||||||
while e.type isnt "Delimiter"
|
|
||||||
n = e.content
|
|
||||||
if not e.isDeleted() and e.content? # TODO: how can this happen? Probably because listeners
|
|
||||||
if n.type is "XmlType"
|
|
||||||
@xml.appendChild n.val(enforce)
|
|
||||||
else if n.type is "TextNodeType"
|
|
||||||
text_node = n.val()
|
|
||||||
@xml.appendChild text_node
|
|
||||||
else
|
|
||||||
throw new Error "Internal structure cannot be transformed to dom"
|
|
||||||
e = e.next_cl
|
|
||||||
@setXmlProxy()
|
|
||||||
@xml
|
|
||||||
|
|
||||||
|
|
||||||
execute: ()->
|
|
||||||
super()
|
|
||||||
###
|
|
||||||
if not @validateSavedOperations()
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
|
|
||||||
return true
|
|
||||||
###
|
|
||||||
|
|
||||||
#
|
|
||||||
# Get the parent of this JsonType.
|
|
||||||
# @return {XmlType}
|
|
||||||
#
|
|
||||||
getParent: ()->
|
|
||||||
@parent
|
|
||||||
|
|
||||||
#
|
|
||||||
# @private
|
|
||||||
#
|
|
||||||
# Convert all relevant information of this operation to the json-format.
|
|
||||||
# This result can be send to other clients.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json =
|
|
||||||
{
|
|
||||||
'type' : @type
|
|
||||||
'attributes' : @attributes.getUid()
|
|
||||||
'elements' : @elements.getUid()
|
|
||||||
'tagname' : @tagname
|
|
||||||
'uid' : @getUid()
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
parser['XmlType'] = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
'attributes' : attributes
|
|
||||||
'elements' : elements
|
|
||||||
'tagname' : tagname
|
|
||||||
} = json
|
|
||||||
|
|
||||||
new XmlType uid, tagname, attributes, elements, undefined
|
|
||||||
|
|
||||||
#
|
|
||||||
# @nodoc
|
|
||||||
# Defines an object that is cannot be changed. You can use this to set an immutable string, or a number.
|
|
||||||
#
|
|
||||||
class TextNodeType extends types.ImmutableObject
|
|
||||||
|
|
||||||
#
|
|
||||||
# @param {Object} uid A unique identifier. If uid is undefined, a new uid will be created.
|
|
||||||
# @param {Object} content
|
|
||||||
#
|
|
||||||
constructor: (uid, content)->
|
|
||||||
if content._y?
|
|
||||||
d = new types.Delete undefined, content._y
|
|
||||||
HB.addOperation(d).execute()
|
|
||||||
content._y = null
|
|
||||||
content._y = @
|
|
||||||
super uid, content
|
|
||||||
|
|
||||||
applyDelete: (op)->
|
|
||||||
if @insert_parent? and not @insert_parent.isDeleted()
|
|
||||||
@insert_parent.applyDelete op
|
|
||||||
else
|
|
||||||
super
|
|
||||||
|
|
||||||
|
|
||||||
type: "TextNodeType"
|
|
||||||
|
|
||||||
#
|
|
||||||
# Encode this operation in such a way that it can be parsed by remote peers.
|
|
||||||
#
|
|
||||||
_encode: ()->
|
|
||||||
json = {
|
|
||||||
'type': @type
|
|
||||||
'uid' : @getUid()
|
|
||||||
'content' : @content.textContent
|
|
||||||
}
|
|
||||||
json
|
|
||||||
|
|
||||||
parser['TextNodeType'] = (json)->
|
|
||||||
{
|
|
||||||
'uid' : uid
|
|
||||||
'content' : content
|
|
||||||
} = json
|
|
||||||
textnode = document.createTextNode content
|
|
||||||
new TextNodeType uid, textnode
|
|
||||||
|
|
||||||
types['XmlType'] = XmlType
|
|
||||||
|
|
||||||
json_types
|
|
||||||
###
|
|
||||||
2910
package-lock.json
generated
Normal file
2910
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
package.json
110
package.json
@@ -1,66 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "yjs",
|
"name": "yjs",
|
||||||
"version": "0.3.0",
|
"version": "13.4.4",
|
||||||
"description": "A Framework that enables Real-Time Collaboration on arbitrary data structures.",
|
"description": "Shared Editing Library",
|
||||||
"main": "./build/node/y.js",
|
"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"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "./node_modules/.bin/gulp test"
|
"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": {
|
||||||
|
"ignore": [
|
||||||
|
"/dist",
|
||||||
|
"/node_modules",
|
||||||
|
"/docs"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/rwth-acis/yjs"
|
"url": "https://github.com/yjs/yjs.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"OT",
|
"Yjs",
|
||||||
"collaboration",
|
"CRDT",
|
||||||
"Yata",
|
"offline",
|
||||||
"synchronization",
|
"shared editing",
|
||||||
"ShareJS",
|
"concurrency",
|
||||||
"Coweb",
|
"collaboration"
|
||||||
"concurrency"
|
|
||||||
],
|
],
|
||||||
"author": "Kevin Jahns",
|
"author": "Kevin Jahns",
|
||||||
"email": "kevin.jahns@rwth-aachen.de",
|
"email": "kevin.jahns@protonmail.com",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/rwth-acis/yjs/issues"
|
"url": "https://github.com/yjs/yjs/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://dadamonad.github.io/yjs/",
|
"homepage": "https://yjs.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"codo": "^2.0.9",
|
"@rollup/plugin-commonjs": "^11.1.0",
|
||||||
"underscore": "^1.6.0",
|
"@rollup/plugin-node-resolve": "^7.1.3",
|
||||||
"chai": "^1.9.1",
|
"concurrently": "^3.6.1",
|
||||||
"codo": "^2.0.9",
|
"http-server": "^0.12.3",
|
||||||
"coffee-errors": "~0.8.6",
|
"jsdoc": "^3.6.5",
|
||||||
"coffee-script": "^1.7.1",
|
"markdownlint-cli": "^0.23.2",
|
||||||
"coffeeify": "^0.6.0",
|
"rollup": "^1.32.1",
|
||||||
"gulp": "^3.8.7",
|
"rollup-cli": "^1.0.9",
|
||||||
"gulp-browserify": "^0.5.0",
|
"standard": "^14.3.4",
|
||||||
"gulp-cached": "^1.0.1",
|
"tui-jsdoc-template": "^1.2.2",
|
||||||
"gulp-coffee": "^2.1.1",
|
"typescript": "^3.9.7",
|
||||||
"gulp-coffeeify": "^0.1.2",
|
"y-protocols": "^0.2.3"
|
||||||
"gulp-coffeelint": "^0.3.3",
|
|
||||||
"gulp-concat": "^2.3.4",
|
|
||||||
"gulp-copy": "0.0.2",
|
|
||||||
"gulp-debug": "^1.0.0",
|
|
||||||
"gulp-git": "^0.5.0",
|
|
||||||
"gulp-if": "^1.2.4",
|
|
||||||
"gulp-ignore": "^1.2.0",
|
|
||||||
"gulp-ljs": "^0.1.1",
|
|
||||||
"gulp-mocha": "^0.5.2",
|
|
||||||
"gulp-mocha-phantomjs": "^0.5.0",
|
|
||||||
"gulp-plumber": "^0.6.6",
|
|
||||||
"gulp-rename": "^1.2.0",
|
|
||||||
"gulp-rimraf": "^0.1.0",
|
|
||||||
"gulp-run": "^1.6.3",
|
|
||||||
"gulp-sourcemaps": "^1.1.1",
|
|
||||||
"gulp-uglify": "^0.3.1",
|
|
||||||
"gulp-watch": "^3.0.0",
|
|
||||||
"jquery": "^2.1.1",
|
|
||||||
"mocha": "^1.21.4",
|
|
||||||
"sinon": "^1.10.2",
|
|
||||||
"sinon-chai": "^2.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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']
|
||||||
|
}]
|
||||||
75
src/index.js
Normal file
75
src/index.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
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,
|
||||||
|
createDocFromSnapshot,
|
||||||
|
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'
|
||||||
41
src/internals.js
Normal file
41
src/internals.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
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/ContentDoc.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())
|
||||||
135
src/structs/ContentDoc.js
Normal file
135
src/structs/ContentDoc.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
Doc, AbstractUpdateDecoder, AbstractUpdateEncoder, StructStore, Transaction, Item // eslint-disable-line
|
||||||
|
} from '../internals.js'
|
||||||
|
|
||||||
|
import * as error from 'lib0/error.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export class ContentDoc {
|
||||||
|
/**
|
||||||
|
* @param {Doc} doc
|
||||||
|
*/
|
||||||
|
constructor (doc) {
|
||||||
|
if (doc._item) {
|
||||||
|
console.error('This document was already integrated as a sub-document. You should create a second instance instead with the same guid.')
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {Doc}
|
||||||
|
*/
|
||||||
|
this.doc = doc
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
const opts = {}
|
||||||
|
this.opts = opts
|
||||||
|
if (!doc.gc) {
|
||||||
|
opts.gc = false
|
||||||
|
}
|
||||||
|
if (doc.autoLoad) {
|
||||||
|
opts.autoLoad = true
|
||||||
|
}
|
||||||
|
if (doc.meta !== null) {
|
||||||
|
opts.meta = doc.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
getLength () {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Array<any>}
|
||||||
|
*/
|
||||||
|
getContent () {
|
||||||
|
return [this.doc]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
isCountable () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {ContentDoc}
|
||||||
|
*/
|
||||||
|
copy () {
|
||||||
|
return new ContentDoc(this.doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} offset
|
||||||
|
* @return {ContentDoc}
|
||||||
|
*/
|
||||||
|
splice (offset) {
|
||||||
|
throw error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentDoc} right
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
mergeWith (right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {Item} item
|
||||||
|
*/
|
||||||
|
integrate (transaction, item) {
|
||||||
|
// this needs to be reflected in doc.destroy as well
|
||||||
|
this.doc._item = item
|
||||||
|
transaction.subdocsAdded.add(this.doc)
|
||||||
|
if (this.doc.shouldLoad) {
|
||||||
|
transaction.subdocsLoaded.add(this.doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
*/
|
||||||
|
delete (transaction) {
|
||||||
|
if (transaction.subdocsAdded.has(this.doc)) {
|
||||||
|
transaction.subdocsAdded.delete(this.doc)
|
||||||
|
} else {
|
||||||
|
transaction.subdocsRemoved.add(this.doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StructStore} store
|
||||||
|
*/
|
||||||
|
gc (store) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {AbstractUpdateEncoder} encoder
|
||||||
|
* @param {number} offset
|
||||||
|
*/
|
||||||
|
write (encoder, offset) {
|
||||||
|
encoder.writeString(this.doc.guid)
|
||||||
|
encoder.writeAny(this.opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
getRef () {
|
||||||
|
return 9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*
|
||||||
|
* @param {AbstractUpdateDecoder} decoder
|
||||||
|
* @return {ContentDoc}
|
||||||
|
*/
|
||||||
|
export const readContentDoc = decoder => new ContentDoc(new Doc({ guid: decoder.readString(), ...decoder.readAny() }))
|
||||||
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)
|
||||||
|
}
|
||||||
112
src/structs/ContentString.js
Normal file
112
src/structs/ContentString.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Prevent encoding invalid documents because of splitting of surrogate pairs: https://github.com/yjs/yjs/issues/248
|
||||||
|
const firstCharCode = this.str.charCodeAt(offset - 1)
|
||||||
|
if (firstCharCode >= 0xD800 && firstCharCode <= 0xDBFF) {
|
||||||
|
// Last character of the left split is the start of a surrogate utf16/ucs2 pair.
|
||||||
|
// We don't support splitting of surrogate pairs because this may lead to invalid documents.
|
||||||
|
// Replace the invalid character with a unicode replacement character (<28> / U+FFFD)
|
||||||
|
this.str = this.str.slice(0, offset - 1) + '<27>'
|
||||||
|
// replace right as well
|
||||||
|
right.str = '<27>' + right.str.slice(1)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
readContentDoc,
|
||||||
|
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 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)
|
||||||
|
addChangedTypeToTransaction(transaction, parent, 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, // 1
|
||||||
|
readContentJSON, // 2
|
||||||
|
readContentBinary, // 3
|
||||||
|
readContentString, // 4
|
||||||
|
readContentEmbed, // 5
|
||||||
|
readContentFormat, // 6
|
||||||
|
readContentType, // 7
|
||||||
|
readContentAny, // 8
|
||||||
|
readContentDoc // 9
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
896
src/types/AbstractType.js
Normal file
896
src/types/AbstractType.js
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
removeEventHandlerListener,
|
||||||
|
callEventHandlerListeners,
|
||||||
|
addEventHandlerListener,
|
||||||
|
createEventHandler,
|
||||||
|
getState,
|
||||||
|
isVisible,
|
||||||
|
ContentType,
|
||||||
|
createID,
|
||||||
|
ContentAny,
|
||||||
|
ContentBinary,
|
||||||
|
getItemCleanStart,
|
||||||
|
ContentDoc, 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {AbstractType<EventType>}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
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
|
||||||
|
* @param {number} start
|
||||||
|
* @param {number} end
|
||||||
|
* @return {Array<any>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const typeListSlice = (type, start, end) => {
|
||||||
|
if (start < 0) {
|
||||||
|
start = type._length + start
|
||||||
|
}
|
||||||
|
if (end < 0) {
|
||||||
|
end = type._length + end
|
||||||
|
}
|
||||||
|
let len = end - start
|
||||||
|
const cs = []
|
||||||
|
let n = type._start
|
||||||
|
while (n !== null && len > 0) {
|
||||||
|
if (n.countable && !n.deleted) {
|
||||||
|
const c = n.content.getContent()
|
||||||
|
if (c.length <= start) {
|
||||||
|
start -= c.length
|
||||||
|
} else {
|
||||||
|
for (let i = start; i < c.length && len > 0; i++) {
|
||||||
|
cs.push(c[i])
|
||||||
|
len--
|
||||||
|
}
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n = n.right
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
case Doc:
|
||||||
|
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (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
|
||||||
|
case Doc:
|
||||||
|
content = new ContentDoc(/** @type {Doc} */ (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)
|
||||||
257
src/types/YArray.js
Normal file
257
src/types/YArray.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* @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'
|
||||||
|
import { typeListSlice } from './AbstractType.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 = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a new YArray containing the specified items.
|
||||||
|
* @template T
|
||||||
|
* @param {Array<T>} items
|
||||||
|
* @return {YArray<T>}
|
||||||
|
*/
|
||||||
|
static from (items) {
|
||||||
|
const a = new YArray()
|
||||||
|
a.push(items)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YArray<T>}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const arr = new YArray()
|
||||||
|
arr.insert(0, this.toArray().map(el =>
|
||||||
|
el instanceof AbstractType ? el.clone() : el
|
||||||
|
))
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
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 YArray to a JavaScript Array.
|
||||||
|
*
|
||||||
|
* @param {number} [start]
|
||||||
|
* @param {number} [end]
|
||||||
|
* @return {Array<T>}
|
||||||
|
*/
|
||||||
|
slice (start = 0, end = this.length) {
|
||||||
|
return typeListSlice(this, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
254
src/types/YMap.js
Normal file
254
src/types/YMap.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* @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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YMap<T>}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const map = new YMap()
|
||||||
|
this.forEach((value, key) => {
|
||||||
|
map.set(key, value instanceof AbstractType ? value.clone() : value)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 values for each element in the YMap Type.
|
||||||
|
*
|
||||||
|
* @return {IterableIterator<any>}
|
||||||
|
*/
|
||||||
|
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()
|
||||||
1129
src/types/YText.js
Normal file
1129
src/types/YText.js
Normal file
File diff suppressed because it is too large
Load Diff
209
src/types/YXmlElement.js
Normal file
209
src/types/YXmlElement.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
YXmlFragment,
|
||||||
|
transact,
|
||||||
|
typeMapDelete,
|
||||||
|
typeMapSet,
|
||||||
|
typeMapGet,
|
||||||
|
typeMapGetAll,
|
||||||
|
typeListForEach,
|
||||||
|
YXmlElementRefID,
|
||||||
|
AbstractType, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YXmlElement}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const el = new YXmlElement(this.nodeName)
|
||||||
|
const attrs = this.getAttributes()
|
||||||
|
for (const key in attrs) {
|
||||||
|
el.setAttribute(key, attrs[key])
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
el.insert(0, el.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
390
src/types/YXmlFragment.js
Normal file
390
src/types/YXmlFragment.js
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* @module YXml
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
YXmlEvent,
|
||||||
|
YXmlElement,
|
||||||
|
AbstractType,
|
||||||
|
typeListMap,
|
||||||
|
typeListForEach,
|
||||||
|
typeListInsertGenerics,
|
||||||
|
typeListDelete,
|
||||||
|
typeListToArray,
|
||||||
|
YXmlFragmentRefID,
|
||||||
|
callTypeObservers,
|
||||||
|
transact,
|
||||||
|
typeListGet,
|
||||||
|
typeListSlice,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YXmlFragment}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const el = new YXmlFragment()
|
||||||
|
// @ts-ignore
|
||||||
|
el.insert(0, el.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends content to this YArray.
|
||||||
|
*
|
||||||
|
* @param {Array<YXmlElement|YXmlText>} content Array of content to append.
|
||||||
|
*/
|
||||||
|
push (content) {
|
||||||
|
this.insert(this.length, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preppends content to this YArray.
|
||||||
|
*
|
||||||
|
* @param {Array<YXmlElement|YXmlText>} content Array of content to preppend.
|
||||||
|
*/
|
||||||
|
unshift (content) {
|
||||||
|
this.insert(0, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the i-th element from a YArray.
|
||||||
|
*
|
||||||
|
* @param {number} index The index of the element to return from the YArray
|
||||||
|
* @return {YXmlElement|YXmlText}
|
||||||
|
*/
|
||||||
|
get (index) {
|
||||||
|
return typeListGet(this, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms this YArray to a JavaScript Array.
|
||||||
|
*
|
||||||
|
* @param {number} [start]
|
||||||
|
* @param {number} [end]
|
||||||
|
* @return {Array<YXmlElement|YXmlText>}
|
||||||
|
*/
|
||||||
|
slice (start = 0, end = this.length) {
|
||||||
|
return typeListSlice(this, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
95
src/types/YXmlHook.js
Normal file
95
src/types/YXmlHook.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YXmlHook}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const el = new YXmlHook(this.hookName)
|
||||||
|
this.forEach((value, key) => {
|
||||||
|
el.set(key, value)
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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())
|
||||||
105
src/types/YXmlText.js
Normal file
105
src/types/YXmlText.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {YXmlText}
|
||||||
|
*/
|
||||||
|
clone () {
|
||||||
|
const text = new YXmlText()
|
||||||
|
text.applyDelta(this.toDelta())
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()))))
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/utils/Doc.js
Normal file
278
src/utils/Doc.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @module Y
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
StructStore,
|
||||||
|
AbstractType,
|
||||||
|
YArray,
|
||||||
|
YText,
|
||||||
|
YMap,
|
||||||
|
YXmlFragment,
|
||||||
|
transact,
|
||||||
|
ContentDoc, 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'
|
||||||
|
import * as array from 'lib0/array.js'
|
||||||
|
|
||||||
|
export const generateNewClientId = random.uint32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DocOpts
|
||||||
|
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
|
||||||
|
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
|
||||||
|
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
|
||||||
|
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
|
||||||
|
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Yjs instance handles the state of shared data.
|
||||||
|
* @extends Observable<string>
|
||||||
|
*/
|
||||||
|
export class Doc extends Observable {
|
||||||
|
/**
|
||||||
|
* @param {DocOpts} [opts] configuration
|
||||||
|
*/
|
||||||
|
constructor ({ guid = random.uuidv4(), gc = true, gcFilter = () => true, meta = null, autoLoad = false } = {}) {
|
||||||
|
super()
|
||||||
|
this.gc = gc
|
||||||
|
this.gcFilter = gcFilter
|
||||||
|
this.clientID = generateNewClientId()
|
||||||
|
this.guid = guid
|
||||||
|
/**
|
||||||
|
* @type {Map<string, AbstractType<YEvent>>}
|
||||||
|
*/
|
||||||
|
this.share = new Map()
|
||||||
|
this.store = new StructStore()
|
||||||
|
/**
|
||||||
|
* @type {Transaction | null}
|
||||||
|
*/
|
||||||
|
this._transaction = null
|
||||||
|
/**
|
||||||
|
* @type {Array<Transaction>}
|
||||||
|
*/
|
||||||
|
this._transactionCleanups = []
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocs = new Set()
|
||||||
|
/**
|
||||||
|
* If this document is a subdocument - a document integrated into another document - then _item is defined.
|
||||||
|
* @type {Item?}
|
||||||
|
*/
|
||||||
|
this._item = null
|
||||||
|
this.shouldLoad = autoLoad
|
||||||
|
this.autoLoad = autoLoad
|
||||||
|
this.meta = meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the parent document that you request to load data into this subdocument (if it is a subdocument).
|
||||||
|
*
|
||||||
|
* `load()` might be used in the future to request any provider to load the most current data.
|
||||||
|
*
|
||||||
|
* It is safe to call `load()` multiple times.
|
||||||
|
*/
|
||||||
|
load () {
|
||||||
|
const item = this._item
|
||||||
|
if (item !== null && !this.shouldLoad) {
|
||||||
|
transact(/** @type {any} */ (item.parent).doc, transaction => {
|
||||||
|
transaction.subdocsLoaded.add(this)
|
||||||
|
}, null, true)
|
||||||
|
}
|
||||||
|
this.shouldLoad = true
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubdocs () {
|
||||||
|
return this.subdocs
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubdocGuids () {
|
||||||
|
return new Set(Array.from(this.subdocs).map(doc => doc.guid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 () {
|
||||||
|
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
|
||||||
|
const item = this._item
|
||||||
|
if (item !== null) {
|
||||||
|
this._item = null
|
||||||
|
const content = /** @type {ContentDoc} */ (item.content)
|
||||||
|
if (item.deleted) {
|
||||||
|
// @ts-ignore
|
||||||
|
content.doc = null
|
||||||
|
} else {
|
||||||
|
content.doc = new Doc({ guid: this.guid, ...content.opts })
|
||||||
|
content.doc._item = item
|
||||||
|
}
|
||||||
|
transact(/** @type {any} */ (item).parent.doc, transaction => {
|
||||||
|
if (!item.deleted) {
|
||||||
|
transaction.subdocsAdded.add(content.doc)
|
||||||
|
}
|
||||||
|
transaction.subdocsRemoved.add(this)
|
||||||
|
}, null, true)
|
||||||
|
}
|
||||||
|
this.emit('destroyed', [true])
|
||||||
|
super.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} eventName
|
||||||
|
* @param {function(...any):any} f
|
||||||
|
*/
|
||||||
|
on (eventName, f) {
|
||||||
|
super.on(eventName, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} eventName
|
||||||
|
* @param {function} f
|
||||||
|
*/
|
||||||
|
off (eventName, f) {
|
||||||
|
super.off(eventName, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/utils/EventHandler.js
Normal file
87
src/utils/EventHandler.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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) => {
|
||||||
|
const l = eventHandler.l
|
||||||
|
const len = l.length
|
||||||
|
eventHandler.l = l.filter(g => f !== g)
|
||||||
|
if (len === eventHandler.l.length) {
|
||||||
|
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(decoding.createDecoder(encodedDs))))))
|
||||||
|
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
|
||||||
|
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
|
||||||
|
)
|
||||||
|
ids.forEach(addClientId)
|
||||||
|
}
|
||||||
|
// observe users
|
||||||
|
storeType.observe(event => {
|
||||||
|
event.keysChanged.forEach(userDescription =>
|
||||||
|
initUser(storeType.get(userDescription), userDescription)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// add intial data
|
||||||
|
storeType.forEach(initUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {number} clientid
|
||||||
|
* @param {string} userDescription
|
||||||
|
* @param {Object} [conf]
|
||||||
|
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
|
||||||
|
*/
|
||||||
|
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
|
||||||
|
const users = this.yusers
|
||||||
|
let user = users.get(userDescription)
|
||||||
|
if (!user) {
|
||||||
|
user = new YMap()
|
||||||
|
user.set('ids', new YArray())
|
||||||
|
user.set('ds', new YArray())
|
||||||
|
users.set(userDescription, user)
|
||||||
|
}
|
||||||
|
user.get('ids').push([clientid])
|
||||||
|
users.observe(event => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const userOverwrite = users.get(userDescription)
|
||||||
|
if (userOverwrite !== user) {
|
||||||
|
// user was overwritten, port all data over to the next user object
|
||||||
|
// @todo Experiment with Y.Sets here
|
||||||
|
user = userOverwrite
|
||||||
|
// @todo iterate over old type
|
||||||
|
this.clients.forEach((_userDescription, clientid) => {
|
||||||
|
if (userDescription === _userDescription) {
|
||||||
|
user.get('ids').push([clientid])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const encoder = 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)
|
||||||
|
)
|
||||||
202
src/utils/Snapshot.js
Normal file
202
src/utils/Snapshot.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
isDeleted,
|
||||||
|
createDeleteSetFromStructStore,
|
||||||
|
getStateVector,
|
||||||
|
getItemCleanStart,
|
||||||
|
iterateDeletedStructs,
|
||||||
|
writeDeleteSet,
|
||||||
|
writeStateVector,
|
||||||
|
readDeleteSet,
|
||||||
|
readStateVector,
|
||||||
|
createDeleteSet,
|
||||||
|
createID,
|
||||||
|
getState,
|
||||||
|
findIndexSS,
|
||||||
|
UpdateEncoderV2,
|
||||||
|
DefaultDSEncoder,
|
||||||
|
applyUpdateV2,
|
||||||
|
AbstractDSDecoder, AbstractDSEncoder, 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 * as encoding from 'lib0/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} originDoc
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
|
||||||
|
* @return {Doc}
|
||||||
|
*/
|
||||||
|
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
|
||||||
|
if (originDoc.gc) {
|
||||||
|
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
|
||||||
|
throw new Error('originDoc must not be garbage collected')
|
||||||
|
}
|
||||||
|
const { sv, ds } = snapshot
|
||||||
|
|
||||||
|
const encoder = new UpdateEncoderV2()
|
||||||
|
originDoc.transact(transaction => {
|
||||||
|
let size = 0
|
||||||
|
sv.forEach(clock => {
|
||||||
|
if (clock > 0) {
|
||||||
|
size++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, size)
|
||||||
|
// splitting the structs before writing them to the encoder
|
||||||
|
for (const [client, clock] of sv) {
|
||||||
|
if (clock === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (clock < getState(originDoc.store, client)) {
|
||||||
|
getItemCleanStart(transaction, createID(client, clock))
|
||||||
|
}
|
||||||
|
const structs = originDoc.store.clients.get(client) || []
|
||||||
|
const lastStructIndex = findIndexSS(structs, clock - 1)
|
||||||
|
// write # encoded structs
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1)
|
||||||
|
encoder.writeClient(client)
|
||||||
|
// first clock written is 0
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||||
|
for (let i = 0; i <= lastStructIndex; i++) {
|
||||||
|
structs[i].write(encoder, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeDeleteSet(encoder, ds)
|
||||||
|
})
|
||||||
|
|
||||||
|
applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot')
|
||||||
|
return newDoc
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
405
src/utils/Transaction.js
Normal file
405
src/utils/Transaction.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
|
||||||
|
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
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsAdded = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsRemoved = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsLoaded = new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
})
|
||||||
|
// sort events by path length so that top-level events are fired first.
|
||||||
|
events
|
||||||
|
.sort((event1, event2) => event1.path.length - event2.path.length)
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transaction.subdocsAdded.forEach(subdoc => doc.subdocs.add(subdoc))
|
||||||
|
transaction.subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
|
||||||
|
|
||||||
|
doc.emit('subdocs', [{ loaded: transaction.subdocsLoaded, added: transaction.subdocsAdded, removed: transaction.subdocsRemoved }])
|
||||||
|
transaction.subdocsRemoved.forEach(subdoc => subdoc.destroy())
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
296
src/utils/UndoManager.js
Normal file
296
src/utils/UndoManager.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transaction.changed.forEach((subProps, type) => {
|
||||||
|
// destroy search marker if necessary
|
||||||
|
if (subProps.has(null) && type._searchMarker) {
|
||||||
|
type._searchMarker.length = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 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 = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
||||||
|
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>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,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))
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
chai = require('chai')
|
|
||||||
expect = chai.expect
|
|
||||||
should = chai.should()
|
|
||||||
sinon = require('sinon')
|
|
||||||
sinonChai = require('sinon-chai')
|
|
||||||
_ = require("underscore")
|
|
||||||
|
|
||||||
chai.use(sinonChai)
|
|
||||||
|
|
||||||
Connector = require "../../y-connectors/lib/y-test/y-test.coffee"
|
|
||||||
|
|
||||||
module.exports = class Test
|
|
||||||
constructor: (@name_suffix = "")->
|
|
||||||
@number_of_test_cases_multiplier = 1
|
|
||||||
@repeat_this = 3 * @number_of_test_cases_multiplier
|
|
||||||
@doSomething_amount = 123 * @number_of_test_cases_multiplier
|
|
||||||
@number_of_engines = 5 + @number_of_test_cases_multiplier - 1
|
|
||||||
|
|
||||||
@time = 0 # denotes to the time when run was started
|
|
||||||
@ops = 0 # number of operations (used with @time)
|
|
||||||
@time_now = 0 # current time
|
|
||||||
|
|
||||||
@debug = false
|
|
||||||
|
|
||||||
@reinitialize()
|
|
||||||
|
|
||||||
reinitialize: ()->
|
|
||||||
@users = []
|
|
||||||
for i in [0...@number_of_engines]
|
|
||||||
u = @makeNewUser (i+@name_suffix)
|
|
||||||
for user in @users
|
|
||||||
u.getConnector().join(user.getConnector()) # TODO: change the test-connector to make this more convenient
|
|
||||||
@users.push u
|
|
||||||
@initUsers?(@users[0])
|
|
||||||
@flushAll()
|
|
||||||
|
|
||||||
# is called by implementing class
|
|
||||||
makeNewUser: (user)->
|
|
||||||
user.HB.setManualGarbageCollect()
|
|
||||||
user
|
|
||||||
|
|
||||||
getSomeUser: ()->
|
|
||||||
i = _.random 0, (@users.length-1)
|
|
||||||
@users[i]
|
|
||||||
|
|
||||||
getRandomText: (chars, min_length = 0)->
|
|
||||||
chars ?= "abcdefghijklmnopqrstuvwxyz"
|
|
||||||
length = _.random min_length, 10
|
|
||||||
#length = 1
|
|
||||||
nextchar = chars[(_.random 0, (chars.length-1))]
|
|
||||||
text = ""
|
|
||||||
_(length).times ()-> text += nextchar
|
|
||||||
text
|
|
||||||
|
|
||||||
getRandomObject: ()->
|
|
||||||
result = {}
|
|
||||||
key1 = @getRandomKey()
|
|
||||||
key2 = @getRandomKey()
|
|
||||||
val1 = @getRandomText()
|
|
||||||
val2 = null
|
|
||||||
if _.random(0,1) is 1
|
|
||||||
val2 = @getRandomObject()
|
|
||||||
else
|
|
||||||
val2 = @getRandomText()
|
|
||||||
result[key1] = val1
|
|
||||||
result[key2] = val2
|
|
||||||
result
|
|
||||||
|
|
||||||
getRandomKey: ()->
|
|
||||||
@getRandomText [1,2,'x','y'], 1 # only 4 keys
|
|
||||||
|
|
||||||
getGeneratingFunctions: (user_num)=>
|
|
||||||
types = @users[user_num].types
|
|
||||||
[
|
|
||||||
f : (y)=> # INSERT TEXT
|
|
||||||
y
|
|
||||||
pos = _.random 0, (y.val().length-1)
|
|
||||||
y.insert pos, @getRandomText()
|
|
||||||
null
|
|
||||||
types: [types.String]
|
|
||||||
,
|
|
||||||
f : (y)-> # DELETE TEXT
|
|
||||||
if y.val().length > 0
|
|
||||||
pos = _.random 0, (y.val().length-1) # TODO: put here also arbitrary number (test behaviour in error cases)
|
|
||||||
length = _.random 0, (y.val().length - pos)
|
|
||||||
ops1 = y.delete pos, length
|
|
||||||
undefined
|
|
||||||
types : [types.String]
|
|
||||||
]
|
|
||||||
getRandomRoot: (user_num)->
|
|
||||||
throw new Error "overwrite me!"
|
|
||||||
|
|
||||||
getContent: (user_num)->
|
|
||||||
throw new Error "overwrite me!"
|
|
||||||
|
|
||||||
generateRandomOp: (user_num)=>
|
|
||||||
y = @getRandomRoot(user_num)
|
|
||||||
choices = @getGeneratingFunctions(user_num).filter (gf)->
|
|
||||||
_.some gf.types, (type)->
|
|
||||||
y instanceof type
|
|
||||||
|
|
||||||
if choices.length is 0
|
|
||||||
console.dir(y)
|
|
||||||
throw new Error "You forgot to specify a test generation methot for this Operation! (#{y.type})"
|
|
||||||
i = _.random 0, (choices.length-1)
|
|
||||||
choices[i].f y
|
|
||||||
|
|
||||||
applyRandomOp: (user_num)=>
|
|
||||||
user = @users[user_num]
|
|
||||||
user.getConnector().flushOneRandom()
|
|
||||||
|
|
||||||
doSomething: ()->
|
|
||||||
user_num = _.random (@number_of_engines-1)
|
|
||||||
choices = [@applyRandomOp, @generateRandomOp]
|
|
||||||
choice = _.random (choices.length-1)
|
|
||||||
choices[choice](user_num)
|
|
||||||
|
|
||||||
flushAll: (final)->
|
|
||||||
# TODO:!!
|
|
||||||
final = false
|
|
||||||
if @users.length <= 1 or not final
|
|
||||||
for user,user_number in @users
|
|
||||||
user.getConnector().flushAll()
|
|
||||||
else
|
|
||||||
for user,user_number in @users[1..]
|
|
||||||
user.getConnector().flushAll()
|
|
||||||
ops = @users[1].getHistoryBuffer()._encode @users[0].HB.getOperationCounter()
|
|
||||||
@users[0].engine.applyOpsCheckDouble ops
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
compareAll: (test_number)->
|
|
||||||
@flushAll(true)
|
|
||||||
|
|
||||||
@time += (new Date()).getTime() - @time_now
|
|
||||||
|
|
||||||
number_of_created_operations = 0
|
|
||||||
for i in [0...(@users.length)]
|
|
||||||
number_of_created_operations += @users[i].getConnector().getOpsInExecutionOrder().length
|
|
||||||
@ops += number_of_created_operations*@users.length
|
|
||||||
|
|
||||||
ops_per_msek = Math.floor(@ops/@time)
|
|
||||||
if test_number? # and @debug
|
|
||||||
console.log "#{test_number}/#{@repeat_this}: #{number_of_created_operations} were created and applied on (#{@users.length}) users ops in a different order." + " Over all we consumed #{@ops} operations in #{@time/1000} seconds (#{ops_per_msek} ops/msek)."
|
|
||||||
|
|
||||||
for i in [0...(@users.length-1)]
|
|
||||||
if @debug
|
|
||||||
if not _.isEqual @getContent(i), @getContent(i+1)
|
|
||||||
printOpsInExecutionOrder = (otnumber, otherotnumber)=>
|
|
||||||
ops = _.filter @users[otnumber].getConnector().getOpsInExecutionOrder(), (o)->
|
|
||||||
typeof o.uid.op_name isnt 'string' and o.uid.creator isnt '_'
|
|
||||||
for s,j in ops
|
|
||||||
console.log "op#{j} = " + (JSON.stringify s)
|
|
||||||
console.log ""
|
|
||||||
s = "ops = ["
|
|
||||||
for o,j in ops
|
|
||||||
if j isnt 0
|
|
||||||
s += ", "
|
|
||||||
s += "op#{j}"
|
|
||||||
s += "]"
|
|
||||||
console.log s
|
|
||||||
console.log "@test_user.engine.applyOps ops"
|
|
||||||
console.log "expect(@test_user.val('name').val()).to.equal(\"#{@users[otherotnumber].val('name').val()}\")"
|
|
||||||
ops
|
|
||||||
console.log ""
|
|
||||||
console.log "Found an OT Puzzle!"
|
|
||||||
console.log "OT states:"
|
|
||||||
for u,j in @users
|
|
||||||
console.log "OT#{j}: "+u.val('name').val()
|
|
||||||
console.log "\nOT execution order (#{i},#{i+1}):"
|
|
||||||
printOpsInExecutionOrder i, i+1
|
|
||||||
console.log ""
|
|
||||||
ops = printOpsInExecutionOrder i+1, i
|
|
||||||
|
|
||||||
console.log ""
|
|
||||||
expect(@getContent(i)).to.deep.equal(@getContent(i+1))
|
|
||||||
|
|
||||||
run: ()->
|
|
||||||
if @debug
|
|
||||||
console.log ''
|
|
||||||
for times in [1..@repeat_this]
|
|
||||||
@time_now = (new Date).getTime()
|
|
||||||
for i in [1..Math.floor(@doSomething_amount/2)]
|
|
||||||
@doSomething()
|
|
||||||
@flushAll(false)
|
|
||||||
for u in @users
|
|
||||||
u.HB.emptyGarbage()
|
|
||||||
for i in [1..Math.floor(@doSomething_amount/2)]
|
|
||||||
@doSomething()
|
|
||||||
|
|
||||||
@compareAll(times)
|
|
||||||
@testHBencoding()
|
|
||||||
if times isnt @repeat_this
|
|
||||||
@reinitialize()
|
|
||||||
|
|
||||||
testHBencoding: ()->
|
|
||||||
# in case of JsonFramework, every user will create its JSON first! therefore, the testusers id must be small than all the others (see InsertType)
|
|
||||||
@users[@users.length] = @makeNewUser (-1) # this does not want to join with anymody
|
|
||||||
|
|
||||||
@users[@users.length-1].HB.renewStateVector @users[0].HB.getOperationCounter()
|
|
||||||
@users[@users.length-1].engine.applyOps @users[0].HB._encode()
|
|
||||||
|
|
||||||
#if @getContent(@users.length-1) isnt @getContent(0)
|
|
||||||
# console.log "testHBencoding:"
|
|
||||||
# console.log "Unprocessed ops first: #{@users[0].engine.unprocessed_ops.length}"
|
|
||||||
# console.log "Unprocessed ops last: #{@users[@users.length-1].engine.unprocessed_ops.length}"
|
|
||||||
expect(@getContent(@users.length-1)).to.deep.equal(@getContent(0))
|
|
||||||
|
|
||||||
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
126
tests/doc.tests.js
Normal file
126
tests/doc.tests.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSubdoc = tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
doc.load() // doesn't do anything
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @type {Array<any>|null}
|
||||||
|
*/
|
||||||
|
let event = /** @type {any} */ (null)
|
||||||
|
doc.on('subdocs', subdocs => {
|
||||||
|
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
|
||||||
|
})
|
||||||
|
const subdocs = doc.getMap('mysubdocs')
|
||||||
|
const docA = new Y.Doc({ guid: 'a' })
|
||||||
|
docA.load()
|
||||||
|
subdocs.set('a', docA)
|
||||||
|
t.compare(event, [['a'], [], ['a']])
|
||||||
|
|
||||||
|
event = null
|
||||||
|
subdocs.get('a').load()
|
||||||
|
t.assert(event === null)
|
||||||
|
|
||||||
|
event = null
|
||||||
|
subdocs.get('a').destroy()
|
||||||
|
t.compare(event, [['a'], ['a'], []])
|
||||||
|
subdocs.get('a').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
subdocs.set('b', new Y.Doc({ guid: 'a' }))
|
||||||
|
t.compare(event, [['a'], [], []])
|
||||||
|
subdocs.get('b').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
const docC = new Y.Doc({ guid: 'c' })
|
||||||
|
docC.load()
|
||||||
|
subdocs.set('c', docC)
|
||||||
|
t.compare(event, [['c'], [], ['c']])
|
||||||
|
|
||||||
|
t.compare(Array.from(doc.getSubdocGuids()), ['a', 'c'])
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
{
|
||||||
|
t.compare(Array.from(doc2.getSubdocs()), [])
|
||||||
|
/**
|
||||||
|
* @type {Array<any>|null}
|
||||||
|
*/
|
||||||
|
let event = /** @type {any} */ (null)
|
||||||
|
doc2.on('subdocs', subdocs => {
|
||||||
|
event = [Array.from(subdocs.added).map(d => d.guid), Array.from(subdocs.removed).map(d => d.guid), Array.from(subdocs.loaded).map(d => d.guid)]
|
||||||
|
})
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
t.compare(event, [['a', 'a', 'c'], [], []])
|
||||||
|
|
||||||
|
doc2.getMap('mysubdocs').get('a').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||||
|
|
||||||
|
doc2.getMap('mysubdocs').delete('a')
|
||||||
|
t.compare(event, [[], ['a'], []])
|
||||||
|
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||||
|
}
|
||||||
|
}
|
||||||
63
tests/encoding.tests.js
Normal file
63
tests/encoding.tests.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as t from 'lib0/testing.js'
|
||||||
|
import * as promise from 'lib0/promise.js'
|
||||||
|
|
||||||
|
import {
|
||||||
|
contentRefs,
|
||||||
|
readContentBinary,
|
||||||
|
readContentDeleted,
|
||||||
|
readContentString,
|
||||||
|
readContentJSON,
|
||||||
|
readContentEmbed,
|
||||||
|
readContentType,
|
||||||
|
readContentFormat,
|
||||||
|
readContentAny,
|
||||||
|
readContentDoc,
|
||||||
|
Doc,
|
||||||
|
PermanentUserData,
|
||||||
|
encodeStateAsUpdate,
|
||||||
|
applyUpdate
|
||||||
|
} from '../src/internals.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testStructReferences = tc => {
|
||||||
|
t.assert(contentRefs.length === 10)
|
||||||
|
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)
|
||||||
|
t.assert(contentRefs[9] === readContentDoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is some custom encoding/decoding happening in PermanentUserData.
|
||||||
|
* This is why it landed here.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testPermanentUserData = async tc => {
|
||||||
|
const ydoc1 = new Doc()
|
||||||
|
const ydoc2 = new Doc()
|
||||||
|
const pd1 = new PermanentUserData(ydoc1)
|
||||||
|
const pd2 = new PermanentUserData(ydoc2)
|
||||||
|
pd1.setUserMapping(ydoc1, ydoc1.clientID, 'user a')
|
||||||
|
pd2.setUserMapping(ydoc2, ydoc2.clientID, 'user b')
|
||||||
|
ydoc1.getText().insert(0, 'xhi')
|
||||||
|
ydoc1.getText().delete(0, 1)
|
||||||
|
ydoc2.getText().insert(0, 'hxxi')
|
||||||
|
ydoc2.getText().delete(1, 2)
|
||||||
|
await promise.wait(10)
|
||||||
|
applyUpdate(ydoc2, encodeStateAsUpdate(ydoc1))
|
||||||
|
applyUpdate(ydoc1, encodeStateAsUpdate(ydoc2))
|
||||||
|
|
||||||
|
// now sync a third doc with same name as doc1 and then create PermanentUserData
|
||||||
|
const ydoc3 = new Doc()
|
||||||
|
applyUpdate(ydoc3, encodeStateAsUpdate(ydoc1))
|
||||||
|
const pd3 = new PermanentUserData(ydoc3)
|
||||||
|
pd3.setUserMapping(ydoc3, ydoc3.clientID, 'user a')
|
||||||
|
}
|
||||||
26
tests/index.js
Normal file
26
tests/index.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
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 * as snapshot from './snapshot.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, snapshot
|
||||||
|
}).then(success => {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
if (isNode) {
|
||||||
|
process.exit(success ? 0 : 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
171
tests/snapshot.tests.js
Normal file
171
tests/snapshot.tests.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals'
|
||||||
|
import * as t from 'lib0/testing.js'
|
||||||
|
import { init } from './testHelper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testBasicRestoreSnapshot = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['hello'])
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(1, ['world'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['hello'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['hello', 'world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testEmptyRestoreSnapshot = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
snap.sv.set(9999, 0)
|
||||||
|
doc.getArray().insert(0, ['world'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray().toArray(), [])
|
||||||
|
t.compare(doc.getArray().toArray(), ['world'])
|
||||||
|
|
||||||
|
// now this snapshot reflects the latest state. It shoult still work.
|
||||||
|
const snap2 = snapshot(doc)
|
||||||
|
const docRestored2 = createDocFromSnapshot(doc, snap2)
|
||||||
|
t.compare(docRestored2.getArray().toArray(), ['world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreSnapshotWithSubType = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, [new YMap()])
|
||||||
|
const subMap = doc.getArray('array').get(0)
|
||||||
|
subMap.set('key1', 'value1')
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
subMap.set('key2', 'value2')
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toJSON(), [{
|
||||||
|
key1: 'value1'
|
||||||
|
}])
|
||||||
|
t.compare(doc.getArray('array').toJSON(), [{
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2'
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreDeletedItem1 = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1', 'item2'])
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').delete(0)
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item2'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreLeftItem = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1'])
|
||||||
|
doc.getMap('map').set('test', 1)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').delete(1)
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItemsBase = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1'])
|
||||||
|
doc.getArray('array').delete(0)
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), [])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItems2 = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
|
||||||
|
doc.getArray('array').delete(1)
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDependentChanges = tc => {
|
||||||
|
const { array0, array1, testConnector } = init(tc, { users: 2 })
|
||||||
|
|
||||||
|
if (!array0.doc) {
|
||||||
|
throw new Error('no document 0')
|
||||||
|
}
|
||||||
|
if (!array1.doc) {
|
||||||
|
throw new Error('no document 1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type Doc
|
||||||
|
*/
|
||||||
|
const doc0 = array0.doc
|
||||||
|
/**
|
||||||
|
* @type Doc
|
||||||
|
*/
|
||||||
|
const doc1 = array1.doc
|
||||||
|
|
||||||
|
doc0.gc = false
|
||||||
|
doc1.gc = false
|
||||||
|
|
||||||
|
array0.insert(0, ['user1item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(1, ['user2item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const snap = snapshot(array0.doc)
|
||||||
|
|
||||||
|
array0.insert(2, ['user1item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(3, ['user2item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const docRestored0 = createDocFromSnapshot(array0.doc, snap)
|
||||||
|
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
|
||||||
|
const docRestored1 = createDocFromSnapshot(array1.doc, snap)
|
||||||
|
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
270
tests/undo-redo.tests.js
Normal file
270
tests/undo-redo.tests.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test case to fix #241
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDoubleUndo = tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const text = doc.getText()
|
||||||
|
text.insert(0, '1221')
|
||||||
|
|
||||||
|
const manager = new Y.UndoManager(text)
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
text.insert(3, '3')
|
||||||
|
|
||||||
|
manager.undo()
|
||||||
|
manager.undo()
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
|
||||||
|
t.compareStrings(text.toString(), '12321')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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)
|
||||||
|
}
|
||||||
555
tests/y-array.tests.js
Normal file
555
tests/y-array.tests.js
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
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 testSlice = tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
const arr = doc1.getArray('array')
|
||||||
|
arr.insert(0, [1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(0), [1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(1), [2, 3])
|
||||||
|
t.compareArrays(arr.slice(0, -1), [1, 2])
|
||||||
|
arr.insert(0, [0])
|
||||||
|
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(0, 2), [0, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://discuss.yjs.dev/t/order-in-which-events-yielded-by-observedeep-should-be-applied/261/2
|
||||||
|
*
|
||||||
|
* Deep observers generate multiple events. When an array added at item at, say, position 0,
|
||||||
|
* and item 1 changed then the array-add event should fire first so that the change event
|
||||||
|
* path is correct. A array binding might lead to an inconsistent state otherwise.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testObserveDeepEventOrder = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Array<any>}
|
||||||
|
*/
|
||||||
|
let events = []
|
||||||
|
array0.observeDeep(e => {
|
||||||
|
events = e
|
||||||
|
})
|
||||||
|
array0.insert(0, [new Y.Map()])
|
||||||
|
users[0].transact(() => {
|
||||||
|
array0.get(0).set('a', 'a')
|
||||||
|
array0.insert(0, [0])
|
||||||
|
})
|
||||||
|
for (let i = 1; i < events.length; i++) {
|
||||||
|
t.assert(events[i - 1].path.length <= events[i].path.length, 'path size increases, fire top-level events first')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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)
|
||||||
|
}
|
||||||
621
tests/y-text.tests.js
Normal file
621
tests/y-text.tests.js
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
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 largeDocumentSize = 100000
|
||||||
|
|
||||||
|
const id = Y.createID(0, 0)
|
||||||
|
const c = new Y.ContentString('a')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testBestCase = tc => {
|
||||||
|
const N = largeDocumentSize
|
||||||
|
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 = largeDocumentSize
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splitting surrogates can lead to invalid encoded documents.
|
||||||
|
*
|
||||||
|
* https://github.com/yjs/yjs/issues/248
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSplitSurrogateCharacter = tc => {
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾') // insert surrogate character
|
||||||
|
// split surrogate, which should not lead to an encoding error
|
||||||
|
text0.insert(1, 'hi!')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾👾') // insert surrogate character
|
||||||
|
// partially delete surrogate
|
||||||
|
text0.delete(1, 2)
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾👾') // insert surrogate character
|
||||||
|
// formatting will also split surrogates
|
||||||
|
text0.format(1, 2, { bold: true })
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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