Compare commits
676
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d77f72ae | ||
|
|
b07a7ba5d8 | ||
|
|
0a7029973b | ||
|
|
b0c4ff5fd5 | ||
|
|
26bcfba18f | ||
|
|
335f4bbfde | ||
|
|
bc235e7498 | ||
|
|
7f08e5b970 | ||
|
|
a257f78d8e | ||
|
|
9cd92e1fd6 | ||
|
|
c253054916 | ||
|
|
d2f067a540 | ||
|
|
59105628f4 | ||
|
|
144b4dc190 | ||
|
|
6a9a306ed7 | ||
|
|
337e9f86ac | ||
|
|
c9cfaa10e7 | ||
|
|
ed06c07c06 | ||
|
|
a49503a1f0 | ||
|
|
81aaafc085 | ||
|
|
b3c8bd6c28 | ||
|
|
ca96ca8771 | ||
|
|
219d8af74f | ||
|
|
49c5928202 | ||
|
|
459c22cf09 | ||
|
|
f25b3c82cd | ||
|
|
2db9355b7b | ||
|
|
8dd9cb49a0 | ||
|
|
64e8822e32 | ||
|
|
a3a3f8ffbe | ||
|
|
e6333e9d7f | ||
|
|
3628acec02 | ||
|
|
ebced635c9 | ||
|
|
1f0812496e | ||
|
|
c62ebf6d09 | ||
|
|
6438d0074d | ||
|
|
130711a9d0 | ||
|
|
7c6e28e163 | ||
|
|
1b432bcaaa | ||
|
|
da89994440 | ||
|
|
c25675d418 | ||
|
|
a58cde9516 | ||
|
|
ccf5a13018 | ||
|
|
4ab4e022a0 | ||
|
|
52abdccc3a | ||
|
|
d134306482 | ||
|
|
d040d8d84a | ||
|
|
c2cd0c0bfc | ||
|
|
2ff56f67bc | ||
|
|
55d9bd043f | ||
|
|
adf3ddf015 | ||
|
|
bfea7dd4db | ||
|
|
7cd24ddcb3 | ||
|
|
241f99bacf | ||
|
|
472a3283c8 | ||
|
|
7b43e33971 | ||
|
|
6af70cb44c | ||
|
|
1fabea49cf | ||
|
|
1bd6355ea8 | ||
|
|
29eb7b01fc | ||
|
|
b446c37315 | ||
|
|
031477cda9 | ||
|
|
a9becf5509 | ||
|
|
79c4b3bf55 | ||
|
|
a61a69e477 | ||
|
|
1daac45c87 | ||
|
|
e3562168ad | ||
|
|
7fa7a48325 | ||
|
|
4925de94a9 | ||
|
|
679e444bf1 | ||
|
|
46a7a533ab | ||
|
|
67aacdebe4 | ||
|
|
7e9cdfca73 | ||
|
|
48b9560e8e | ||
|
|
f9f1e27bc3 | ||
|
|
62699769d5 | ||
|
|
5608dc5661 | ||
|
|
29e92a9702 | ||
|
|
0e0da3b1e9 | ||
|
|
d273e1d722 | ||
|
|
c799dff042 | ||
|
|
ae7ad1b93e | ||
|
|
c675dc02e9 | ||
|
|
ab760f35fa | ||
|
|
8a436791de | ||
|
|
d16a1776e6 | ||
|
|
ab720e0267 | ||
|
|
d7db71e38a | ||
|
|
b466e8430e | ||
|
|
ea404df79b | ||
|
|
15cdb7b64a | ||
|
|
340911f271 | ||
|
|
44d15c0f5e | ||
|
|
c36e51fb9f | ||
|
|
82f7371fed | ||
|
|
e56451e027 | ||
|
|
1ed126e684 | ||
|
|
a85bc09092 | ||
|
|
5cc29dfebe | ||
|
|
6079e5e1ba | ||
|
|
dabbf5700a | ||
|
|
13e5e834a2 | ||
|
|
bf1a7581e8 | ||
|
|
d092480cf3 | ||
|
|
94f7dafc72 | ||
|
|
8d7db5501a | ||
|
|
f6cf766986 | ||
|
|
4bfe42458e | ||
|
|
f6f9668044 | ||
|
|
2b13fde292 | ||
|
|
5aed4ea7a0 | ||
|
|
5dd1ec6b63 | ||
|
|
49f800bacb | ||
|
|
83ba64c8c8 | ||
|
|
49629cfb61 | ||
|
|
6160617458 | ||
|
|
1166479277 | ||
|
|
958e7aa62c | ||
|
|
aa3479d508 | ||
|
|
830df8c44b | ||
|
|
629d9639d2 | ||
|
|
1a529f598f | ||
|
|
f3d580b8eb | ||
|
|
7414ac0926 | ||
|
|
c05fa21dcc | ||
|
|
61e194517d | ||
|
|
9946dff29d | ||
|
|
d72e91a7b8 | ||
|
|
832cf7d4e7 | ||
|
|
b92fda6279 | ||
|
|
7f551dbbe6 | ||
|
|
5dd2fe4bd0 | ||
|
|
d9e70427af | ||
|
|
739ef6dfe2 | ||
|
|
48c560c979 | ||
|
|
b1367c2178 | ||
|
|
ef6aa4b09d | ||
|
|
ea6ae3d7e2 | ||
|
|
01cd13c712 | ||
|
|
e41499eda3 | ||
|
|
6c08c490b1 | ||
|
|
b237dd64eb | ||
|
|
e943ffad49 | ||
|
|
0e203d3ebd | ||
|
|
c532b06b1a | ||
|
|
7d709fecc5 | ||
|
|
b87538cfe4 | ||
|
|
7172054b91 | ||
|
|
c70bfe8cfa | ||
|
|
fe7d04457a | ||
|
|
2c717b670e | ||
|
|
cb062d49b1 | ||
|
|
def809f6bd | ||
|
|
7da0081ace | ||
|
|
ee6f595880 | ||
|
|
d991139b0a | ||
|
|
eb9121de12 | ||
|
|
2aaa2e872f | ||
|
|
6f26aec073 | ||
|
|
f9a9686d76 | ||
|
|
6b7a4803a4 | ||
|
|
5817555d91 | ||
|
|
5a7ee057c3 | ||
|
|
75126f180d | ||
|
|
74777b3791 | ||
|
|
c9c235b85f | ||
|
|
d7ce5aeb42 | ||
|
|
8221a1ca48 | ||
|
|
0ebad9e207 | ||
|
|
ce50b65cde | ||
|
|
ceaeb1eb23 | ||
|
|
cf3957c2a2 | ||
|
|
9fd3a032a9 | ||
|
|
f8842d2ce2 | ||
|
|
ee14854139 | ||
|
|
4d50ec39f3 | ||
|
|
41874f6d44 | ||
|
|
2275404e9d | ||
|
|
956a584322 | ||
|
|
cc308b81c9 | ||
|
|
19b82c3d44 | ||
|
|
ae928c3713 | ||
|
|
1a2d752ca4 | ||
|
|
d3e2d137ea | ||
|
|
1da5a3c193 | ||
|
|
273a2c06c0 | ||
|
|
3ace70a439 | ||
|
|
bc4b24f049 | ||
|
|
b5c5c8e7b0 | ||
|
|
6b09a7ef2d | ||
|
|
58aa3c0b67 | ||
|
|
b577f4b2d4 | ||
|
|
1dbf0ab5f6 | ||
|
|
99dd67e359 | ||
|
|
7475360115 | ||
|
|
470c704542 | ||
|
|
0683cdbaa2 | ||
|
|
569f3ca232 | ||
|
|
4052221168 | ||
|
|
400f3ecf95 | ||
|
|
5ce27dd981 | ||
|
|
91fc0895b2 | ||
|
|
48c23374a4 | ||
|
|
829ee28cf9 | ||
|
|
39876254ca | ||
|
|
0b5a038c9d | ||
|
|
cc702bb556 | ||
|
|
c9e61440f4 | ||
|
|
4d1c6e4702 | ||
|
|
881648a87e | ||
|
|
d3c8efd712 | ||
|
|
9777e1782f | ||
|
|
9391b17366 | ||
|
|
6b42be2841 | ||
|
|
6a07387688 | ||
|
|
d963baf96e | ||
|
|
70ea4f59a0 | ||
|
|
4350d7e13a | ||
|
|
2eaf136c75 | ||
|
|
5c35d5e589 | ||
|
|
e90ef3af9e | ||
|
|
0479af715b | ||
|
|
8aafb909a6 | ||
|
|
c9f6816fb6 | ||
|
|
e1e7552462 | ||
|
|
64787974d8 | ||
|
|
1052420e34 | ||
|
|
a54971bc56 | ||
|
|
f27f4b336c | ||
|
|
bf25d3cc60 | ||
|
|
87af8a8c30 | ||
|
|
df6c1cceee | ||
|
|
987e09d8bc | ||
|
|
6ae68fc546 | ||
|
|
22444a2000 | ||
|
|
92f029830f | ||
|
|
8b13bf48e7 | ||
|
|
285baa4c83 | ||
|
|
7c22576783 | ||
|
|
e11017672f | ||
|
|
f4a736db62 | ||
|
|
2b242befe1 | ||
|
|
3039d5cb47 | ||
|
|
4bf6dc0db6 | ||
|
|
795663aafa | ||
|
|
bd1ca7e2ab | ||
|
|
89f648ab8f | ||
|
|
ff1a0d97d3 | ||
|
|
a11aae3573 | ||
|
|
6c8402948a | ||
|
|
a5df32931c | ||
|
|
835231d291 | ||
|
|
4c31e1fe5d | ||
|
|
ebff808b31 | ||
|
|
224296c1d8 | ||
|
|
4ec52d9282 | ||
|
|
74ad9f44d0 | ||
|
|
a0e54ff7bd | ||
|
|
96dddeb34d | ||
|
|
6528a0a26a | ||
|
|
c530984c97 | ||
|
|
643a7ca760 | ||
|
|
aa0d62ba11 | ||
|
|
110b95d854 | ||
|
|
db0e97b988 | ||
|
|
a2965bc3ca | ||
|
|
a24c66b554 | ||
|
|
b17e136225 | ||
|
|
3be0ad92f5 | ||
|
|
a35b1afa41 | ||
|
|
4d1a0ae07b | ||
|
|
b7b8d02dd0 | ||
|
|
12b0a27cf8 | ||
|
|
cd5a858dcc | ||
|
|
034fccaebb | ||
|
|
8fd44b8d5b | ||
|
|
a5c4036927 | ||
|
|
72e8e583a9 | ||
|
|
5befc1221c | ||
|
|
bc57d8660e | ||
|
|
5aa0d4a921 | ||
|
|
8320bb0651 | ||
|
|
50180ed72a | ||
|
|
1f702e4960 | ||
|
|
af4ef4865b | ||
|
|
37716245f7 | ||
|
|
097f6f0f34 | ||
|
|
0eda1c0b8b | ||
|
|
3124112006 | ||
|
|
ee446eb2b8 | ||
|
|
866ca5f747 | ||
|
|
41019ffa9a | ||
|
|
64e156085b | ||
|
|
501c983b99 | ||
|
|
3005deffc5 | ||
|
|
f166156b86 | ||
|
|
9250ac3eaa | ||
|
|
6111a40dc3 | ||
|
|
93d02aadc0 | ||
|
|
c97c6c2199 | ||
|
|
b3093087f6 | ||
|
|
d29ad6345b | ||
|
|
7c725556b0 | ||
|
|
df26e5176f | ||
|
|
3b2c7caf07 | ||
|
|
d039f6534a | ||
|
|
4d08026b77 | ||
|
|
9d1df6126b | ||
|
|
ac308717f9 | ||
|
|
4c0de566f0 | ||
|
|
324dd0b4b2 | ||
|
|
5ed07028c7 | ||
|
|
9f06808026 | ||
|
|
23c93e9e92 | ||
|
|
65faee55b7 | ||
|
|
87e0b91543 | ||
|
|
bb084867fa | ||
|
|
b7da3f80f7 | ||
|
|
893f8625d5 | ||
|
|
355f1929b2 | ||
|
|
059a1a98ab | ||
|
|
ea0fc8a621 | ||
|
|
631cc43f2b | ||
|
|
d0b2cf49da | ||
|
|
bbb1fd2dae | ||
|
|
176698a299 | ||
|
|
4dbe52126e | ||
|
|
3fd8c43212 | ||
|
|
75c4764da8 | ||
|
|
afd5aaaada | ||
|
|
cd6f1ca613 | ||
|
|
8da3cdedd9 | ||
|
|
a72e13bb5c | ||
|
|
9ddc37add5 | ||
|
|
cb267911f5 | ||
|
|
d6eb0533c6 | ||
|
|
b76fa8106e | ||
|
|
4a8dbfadba | ||
|
|
15df6a2fcb | ||
|
|
506128c5df | ||
|
|
1195ea94b3 | ||
|
|
90894781cd | ||
|
|
18cd3c4591 | ||
|
|
4091da60c6 | ||
|
|
4a143ae8e2 | ||
|
|
2159375e81 | ||
|
|
5742878799 | ||
|
|
592c3addac | ||
|
|
9beb411c14 | ||
|
|
21be4caf82 | ||
|
|
6e7c7467a3 | ||
|
|
b5be8ca4fc | ||
|
|
66b4a427b7 | ||
|
|
3a65f2382d | ||
|
|
c677afc04a | ||
|
|
4973083133 | ||
|
|
9288094c30 | ||
|
|
fe428c7cea | ||
|
|
423fe7e0d6 | ||
|
|
c9905606a1 | ||
|
|
e0cbcd21a8 | ||
|
|
214f6d0862 | ||
|
|
80526e58f7 | ||
|
|
b2bb5b2bef | ||
|
|
c9efb9e017 | ||
|
|
34b078e7e8 | ||
|
|
e9bc0c24de | ||
|
|
bfc7535d54 | ||
|
|
dcdc19d15e | ||
|
|
a9db7e0513 | ||
|
|
d92c1e5e4c | ||
|
|
99255ab693 | ||
|
|
ec40c0369c | ||
|
|
a56be6ae14 | ||
|
|
e29170a1aa | ||
|
|
d7cd301e15 | ||
|
|
393a5f659e | ||
|
|
71a336c41f | ||
|
|
e394525376 | ||
|
|
6175561449 | ||
|
|
feb3ba88a5 | ||
|
|
da75a4951a | ||
|
|
02ef7eab78 | ||
|
|
f951ad055b | ||
|
|
9e453e84f6 | ||
|
|
4e7c42f59a | ||
|
|
44e44ede97 | ||
|
|
b624ecb859 | ||
|
|
b13e351460 | ||
|
|
30abb2303b | ||
|
|
bb29c4af7f | ||
|
|
30cb61b966 | ||
|
|
7abe8f82b1 | ||
|
|
393e7efbda | ||
|
|
83715bb250 | ||
|
|
6a55d67c4e | ||
|
|
f16180ec59 | ||
|
|
2883031609 | ||
|
|
b921101cd5 | ||
|
|
1e89e2b4be | ||
|
|
050dafa409 | ||
|
|
0532e403bf | ||
|
|
0f3cfbdeac | ||
|
|
21644297d1 | ||
|
|
3d279652b4 | ||
|
|
c3faf2ff42 | ||
|
|
72616fca7a | ||
|
|
51b187be6a | ||
|
|
e91e190601 | ||
|
|
40c66ce6e3 | ||
|
|
57fc6ede1e | ||
|
|
62f60867f2 | ||
|
|
91331bda7e | ||
|
|
86c7425271 | ||
|
|
73dd347bab | ||
|
|
b9060ddc31 | ||
|
|
fcc7a03650 | ||
|
|
2fae6f7798 | ||
|
|
d8cdf2f42c | ||
|
|
f608761fa2 | ||
|
|
e33aed9cc6 | ||
|
|
3c2acccf52 | ||
|
|
0cd51750b8 | ||
|
|
f6ce710c1b | ||
|
|
433a301776 | ||
|
|
bb184a20ca | ||
|
|
7ca468d42f | ||
|
|
5d2f1e4973 | ||
|
|
db2e8e0297 | ||
|
|
235d3c252d | ||
|
|
9934429c4f | ||
|
|
1f55da1f45 | ||
|
|
9c5ef434dc | ||
|
|
4968e224ae | ||
|
|
ac812ca1e4 | ||
|
|
c1ecd9cd04 | ||
|
|
68371dae63 | ||
|
|
009e4f2390 | ||
|
|
50696445cd | ||
|
|
bd7ca5a25a | ||
|
|
b8aa272bc6 | ||
|
|
66566bd6af | ||
|
|
3075b63d4a | ||
|
|
c20996488d | ||
|
|
0871de7838 | ||
|
|
20cadcab79 | ||
|
|
fb5894e093 | ||
|
|
fa0498f75d | ||
|
|
cdc7f9faa4 | ||
|
|
51591ba075 | ||
|
|
78ccc2ff4c | ||
|
|
fe8e51fa79 | ||
|
|
e0b660d56b | ||
|
|
03404e8e59 | ||
|
|
edc36351d4 | ||
|
|
66c155bab7 | ||
|
|
ee3f4420f7 | ||
|
|
b35ce74065 | ||
|
|
31616cadc0 | ||
|
|
7389a9fdad | ||
|
|
96c8333bbb | ||
|
|
f45b04e16a | ||
|
|
5c0c471ff6 | ||
|
|
fccfd6fbe2 | ||
|
|
9a70c08a6f | ||
|
|
77234d95b3 | ||
|
|
1df59ae5dd | ||
|
|
7e043e7e7e | ||
|
|
8ddf56191d | ||
|
|
942840fb62 | ||
|
|
bb224b486a | ||
|
|
23bb5d63dc | ||
|
|
abf80da2c1 | ||
|
|
276b49e554 | ||
|
|
bb7600f8c5 | ||
|
|
0b1dd4b06d | ||
|
|
dc815cdc4d | ||
|
|
c5eaf340b4 | ||
|
|
4cead1811f | ||
|
|
1b7065609b | ||
|
|
e98a7b8647 | ||
|
|
922f82cc31 | ||
|
|
f5f5038c69 | ||
|
|
d6288608be | ||
|
|
d36571491f | ||
|
|
a7e14c77cb | ||
|
|
dab0e8880f | ||
|
|
f2e2fc4e11 | ||
|
|
54b7655015 | ||
|
|
681f434574 | ||
|
|
2d7252c640 | ||
|
|
e97c727170 | ||
|
|
4233a1e21e | ||
|
|
8735b0f52b | ||
|
|
975e34b2fd | ||
|
|
c77fc8c979 | ||
|
|
f3b62ce674 | ||
|
|
cc562549a9 | ||
|
|
2438363c03 | ||
|
|
bd9625a3f9 | ||
|
|
7df3df748b | ||
|
|
e37253645c | ||
|
|
502e0b5fc3 | ||
|
|
13d7f878f1 | ||
|
|
bb0ab7c7e2 | ||
|
|
939ef8b56e | ||
|
|
1e6e994413 | ||
|
|
ba1c0da871 | ||
|
|
5b103acb11 | ||
|
|
b658ceedc9 | ||
|
|
dc114d55c9 | ||
|
|
90716ef8d5 | ||
|
|
f954dfab61 | ||
|
|
877cbdb6f9 | ||
|
|
09e705c3ae | ||
|
|
83f3c07f10 | ||
|
|
3e3a0162bf | ||
|
|
8c1c348f68 | ||
|
|
f7f3e35fcf | ||
|
|
5acd3faacc | ||
|
|
4c24b52e12 | ||
|
|
8fd965624f | ||
|
|
e2d7fd46ac | ||
|
|
09616ed156 | ||
|
|
118f03385e | ||
|
|
ee156c7e69 | ||
|
|
e80f0865f5 | ||
|
|
aa9e9c8a23 | ||
|
|
75697b7ce1 | ||
|
|
98f7b1b3d1 | ||
|
|
84db2c7c6a | ||
|
|
d8401a8f13 | ||
|
|
6816bd986c | ||
|
|
95c858936d | ||
|
|
c67b84a458 | ||
|
|
765c35265f | ||
|
|
ecc34aabc5 | ||
|
|
6517053b8e | ||
|
|
190bac8c70 | ||
|
|
cfdb4fbf61 | ||
|
|
8cf4ac432d | ||
|
|
5ba65bc032 | ||
|
|
2fe3df56b7 | ||
|
|
659e82e9ae | ||
|
|
76c937a878 | ||
|
|
98cbcf1abe | ||
|
|
6bdabf9325 | ||
|
|
4c06db65f4 | ||
|
|
cc963db471 | ||
|
|
8190a9433f | ||
|
|
4415849a6d | ||
|
|
d5123c6a26 | ||
|
|
60e2b307e9 | ||
|
|
1c04d7d77b | ||
|
|
f59a2dc6d7 | ||
|
|
3b0fae39c9 | ||
|
|
6e262b3ea4 | ||
|
|
267c6160be | ||
|
|
8fb2114831 | ||
|
|
7bae587c8c | ||
|
|
f66331852e | ||
|
|
c06828dbf2 | ||
|
|
e4092a6be2 | ||
|
|
9ffa715d03 | ||
|
|
8fdd959079 | ||
|
|
887545323d | ||
|
|
bc72088fe9 | ||
|
|
2077d37d3a | ||
|
|
4781d46c03 | ||
|
|
4a08f6dad5 | ||
|
|
806699d478 | ||
|
|
07595b54a6 | ||
|
|
370a03f708 | ||
|
|
e88bb8ba30 | ||
|
|
b3a3a0ed51 | ||
|
|
fbf2bcb202 | ||
|
|
762f8f00a3 | ||
|
|
21ba23d95d | ||
|
|
4d4dde003b | ||
|
|
eaf8eb4245 | ||
|
|
d023a1142b | ||
|
|
571c58e5b9 | ||
|
|
3fbe4a80c8 | ||
|
|
2cdc8ed09e | ||
|
|
48333cab3b | ||
|
|
dd7ff496c7 | ||
|
|
34207c76b3 | ||
|
|
15bb56bd7e | ||
|
|
634921dd65 | ||
|
|
c77ccfeaba | ||
|
|
79651594ed | ||
|
|
c40d83d068 | ||
|
|
c3fae5817b | ||
|
|
f736e1545e | ||
|
|
2916eaedaa | ||
|
|
d529226922 | ||
|
|
910fdd76d8 | ||
|
|
9dc4f5773c | ||
|
|
4aad53032f | ||
|
|
2815f35334 | ||
|
|
82b9bfe64a | ||
|
|
96b20fc12c | ||
|
|
36f9cfbb4a | ||
|
|
a01fe0b044 | ||
|
|
29d0feaab1 | ||
|
|
6e16716376 | ||
|
|
b58577b72d | ||
|
|
0671a9b2f1 | ||
|
|
ff092df72f | ||
|
|
28ef0e07d3 | ||
|
|
996a20d90e | ||
|
|
5b1979d420 | ||
|
|
eb62ba0b16 | ||
|
|
46154948bb | ||
|
|
cbeb633895 | ||
|
|
1da5938ff2 | ||
|
|
5621afc874 | ||
|
|
d4602888f7 | ||
|
|
3a3641b9d0 | ||
|
|
84d666f558 | ||
|
|
2133625261 | ||
|
|
34ae0ab1c9 | ||
|
|
a782f60d08 | ||
|
|
32a90daaeb | ||
|
|
e9c11a9e1e | ||
|
|
0c5002bdad | ||
|
|
394206340b | ||
|
|
6db6b6d8cd | ||
|
|
c4b848efd4 | ||
|
|
e66630f6e1 | ||
|
|
903106d34b | ||
|
|
d1da3ac52c | ||
|
|
3b8209b3fe | ||
|
|
ea2d8e843c | ||
|
|
8909b9e54d | ||
|
|
62906cba24 | ||
|
|
2a4a700614 | ||
|
|
8a2bfcf02e | ||
|
|
e9a0378c2c | ||
|
|
e703aec424 | ||
|
|
582c9662f2 | ||
|
|
6d67f0cd42 | ||
|
|
aca7f07aa8 | ||
|
|
368e741675 | ||
|
|
9695512981 | ||
|
|
3baba33e65 | ||
|
|
49edf2e8e2 | ||
|
|
beca75bbba | ||
|
|
ea0553342c | ||
|
|
8b673659d6 | ||
|
|
994a87b103 | ||
|
|
f7046c27c5 | ||
|
|
fdd2581b64 | ||
|
|
8fd1c154d6 | ||
|
|
f89b2f5698 | ||
|
|
6de7cbc520 | ||
|
|
f7ae8d19e6 | ||
|
|
611fda5eff | ||
|
|
c0101cdd4f | ||
|
|
4f66f40779 | ||
|
|
552df747fd | ||
|
|
2e25c8475a | ||
|
|
0abf806f24 | ||
|
|
f4d24c5ab8 | ||
|
|
d0da4a07b4 | ||
|
|
d8ba3747bf | ||
|
|
9a15de1467 | ||
|
|
9e0fa86127 | ||
|
|
bb526e1534 | ||
|
|
8cb7bffc28 | ||
|
|
4fda534c6a | ||
|
|
f64eaaa528 | ||
|
|
4994ecf1dd | ||
|
|
3ce91d9d22 | ||
|
|
182b488552 | ||
|
|
17f27de9bd |
@@ -1,2 +1,9 @@
|
|||||||
MERMAID_DOMAIN="mermaid.live"
|
MERMAID_DOMAIN=''
|
||||||
MERMAID_ANALYTICS_URL="https://p.mermaid.live"
|
MERMAID_BASE_PATH=''
|
||||||
|
MERMAID_DOCS_URL='https://mermaid.js.org'
|
||||||
|
MERMAID_ANALYTICS_URL=''
|
||||||
|
MERMAID_RENDERER_URL='https://mermaid.ink'
|
||||||
|
MERMAID_KROKI_RENDERER_URL='https://kroki.io'
|
||||||
|
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS='true'
|
||||||
|
|
||||||
|
# cp .env .env.local to make local changes
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
docs/**
|
|
||||||
.svelte-kit/**
|
|
||||||
static/**
|
|
||||||
build/**
|
|
||||||
node_modules/**
|
|
||||||
coverage/**
|
|
||||||
__snapshots__/**
|
|
||||||
snapshots.js
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
// 'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
|
||||||
'plugin:@typescript-eslint/strict',
|
|
||||||
'plugin:unicorn/recommended',
|
|
||||||
'plugin:svelte/recommended',
|
|
||||||
'plugin:svelte/prettier',
|
|
||||||
'prettier'
|
|
||||||
],
|
|
||||||
plugins: ['tailwindcss', '@typescript-eslint', 'es', 'vitest', 'no-only-tests', 'unicorn'],
|
|
||||||
ignorePatterns: [
|
|
||||||
'docs/*',
|
|
||||||
'*.cjs',
|
|
||||||
'*.js',
|
|
||||||
'*.md',
|
|
||||||
'snapshots.js',
|
|
||||||
'svelte.config.js',
|
|
||||||
'renovate.json',
|
|
||||||
'package.json',
|
|
||||||
'tsconfig.json'
|
|
||||||
],
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ['*.svelte'],
|
|
||||||
parser: 'svelte-eslint-parser',
|
|
||||||
parserOptions: {
|
|
||||||
parser: '@typescript-eslint/parser'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['*.ts'],
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
|
||||||
'plugin:@typescript-eslint/strict',
|
|
||||||
'prettier'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
project: './tsconfig.json',
|
|
||||||
extraFileExtensions: ['.svelte']
|
|
||||||
},
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
es2020: true
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/ban-ts-comment': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
'ts-ignore': 'allow-with-description'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
||||||
'es/no-regexp-lookbehind-assertions': 'error',
|
|
||||||
curly: ['error', 'all'],
|
|
||||||
'no-only-tests/no-only-tests': 'error',
|
|
||||||
'unicorn/no-null': 'off',
|
|
||||||
'unicorn/filename-case': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
case: 'camelCase'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'unicorn/filename-case': 'off',
|
|
||||||
'unicorn/prevent-abbreviations': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
allowList: {
|
|
||||||
ctx: true,
|
|
||||||
db: true,
|
|
||||||
doc: true,
|
|
||||||
env: true,
|
|
||||||
fn: true,
|
|
||||||
i: true,
|
|
||||||
param: true,
|
|
||||||
req: true,
|
|
||||||
res: true,
|
|
||||||
str: true,
|
|
||||||
searchParams: true,
|
|
||||||
temp: true,
|
|
||||||
ImportMetaEnv: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Docker Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.forkless.com
|
||||||
|
username: ${{ secrets.GITEAUSER }}
|
||||||
|
password: ${{ secrets.GITEATOKEN }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: gitea.forkless.com/forkless/mermaid-live-editor
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
|
||||||
|
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: mermaid
|
||||||
|
push: ${{ github.event_name == 'push' }}
|
||||||
|
pull: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
MERMAID_RENDERER_URL=${{ vars.MERMAID_RENDERER_URL || 'https://mermaid.ink' }}
|
||||||
|
MERMAID_KROKI_RENDERER_URL=${{ vars.MERMAID_KROKI_RENDERER_URL || 'https://kroki.io' }}
|
||||||
|
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=false
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Close broken link issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-broken-link-issues:
|
||||||
|
if: github.event.issue.state == 'open'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close issues with only "Broken link" in the title
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = context.payload.issue.title.trim();
|
||||||
|
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
|
||||||
|
|
||||||
|
if (!isBrokenLinkOnlyTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const issueNumber = context.payload.issue.number;
|
||||||
|
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
|
||||||
|
|
||||||
|
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
|
||||||
|
|
||||||
|
const commentBody = [
|
||||||
|
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
|
||||||
|
'',
|
||||||
|
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
|
||||||
|
'',
|
||||||
|
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
|
||||||
|
'',
|
||||||
|
'If you are reporting a live editor bug, please include:',
|
||||||
|
'- The full URL that failed to load',
|
||||||
|
'- What you expected to happen',
|
||||||
|
'- Any error messages you saw',
|
||||||
|
'',
|
||||||
|
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body: commentBody
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned'
|
||||||
|
});
|
||||||
@@ -14,28 +14,49 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version-file: '.node-version'
|
||||||
cache: yarn
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
with:
|
||||||
|
static_site_generator: sveltekit
|
||||||
|
|
||||||
- name: Build & Deploy
|
- name: Build & Deploy
|
||||||
env:
|
env:
|
||||||
MERMAID_DOMAIN: 'mermaid.live'
|
MERMAID_DOMAIN: 'mermaid.live'
|
||||||
MERMAID_ANALYTICS_URL: 'https://p.mermaid.live'
|
MERMAID_ANALYTICS_URL: 'https://p.mermaid.live'
|
||||||
|
MERMAID_RENDERER_URL: 'https://mermaid.ink'
|
||||||
|
MERMAID_KROKI_RENDERER_URL: 'https://kroki.io'
|
||||||
|
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS: 'true'
|
||||||
run: |
|
run: |
|
||||||
export DEPLOY=true
|
export DEPLOY=true
|
||||||
[ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/
|
[ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/
|
||||||
yarn install
|
pnpm install
|
||||||
version=$(yarn version --patch --no-git-tag-version | grep "New version" | cut -d':' -f 2)
|
pnpm build
|
||||||
yarn build
|
|
||||||
yarn run lint
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Upload artifact
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
if: ${{ github.ref == 'refs/heads/master' }}
|
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
path: ./docs
|
||||||
publish_dir: ./docs
|
|
||||||
keep_files: true
|
deploy:
|
||||||
|
if: ${{ github.ref == 'refs/heads/master' }}
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||||
|
permissions:
|
||||||
|
pages: write # to deploy to Pages
|
||||||
|
id-token: write # to verify the deployment originates from an appropriate source
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
@@ -17,32 +17,43 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-qemu-action@v3
|
- uses: docker/setup-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Get release version
|
|
||||||
run: |
|
|
||||||
RELEASE_VERSION=$([ "${{ github.ref_name }}" = "master" ] && echo "latest" || echo "nightly")
|
|
||||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_ENV}"
|
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v5
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=${{ env.RELEASE_VERSION }}
|
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
|
||||||
|
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
|
||||||
- uses: docker/build-push-action@v5
|
- uses: docker/build-push-action@v5
|
||||||
|
id: build
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: mermaid
|
target: mermaid
|
||||||
push: ${{ github.event_name == 'push' }}
|
push: ${{ github.event_name == 'push' }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
- name: Generate Build Attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/${{ github.repository }}
|
||||||
|
subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
|||||||
+31
-27
@@ -5,47 +5,51 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
merge_group:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cypress-run:
|
playwright:
|
||||||
|
name: 'Playwright Tests'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
container:
|
||||||
fail-fast: false
|
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||||
matrix:
|
|
||||||
# run 3 copies of the current job in parallel
|
|
||||||
containers: [1, 2, 3]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v4
|
||||||
id: yarn-and-build-cache
|
id: pnpm-and-build-cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/Cypress
|
|
||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
|
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node_modules-build-
|
${{ runner.os }}-node_modules-build-
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- name: Install pnpm
|
||||||
with:
|
uses: pnpm/action-setup@v4
|
||||||
node-version: 18
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
# Install NPM dependencies, cache them correctly
|
- uses: actions/setup-node@v4
|
||||||
# and run all Cypress tests
|
|
||||||
- name: Cypress run
|
|
||||||
uses: cypress-io/github-action@v3
|
|
||||||
with:
|
with:
|
||||||
build: yarn build
|
node-version-file: '.node-version'
|
||||||
start: yarn preview
|
cache: 'pnpm'
|
||||||
wait-on: 'http://localhost:3000'
|
|
||||||
record: true
|
- name: Install dependencies
|
||||||
headless: true
|
run: pnpm install
|
||||||
parallel: true
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: pnpm test:e2e
|
||||||
env:
|
env:
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
CI: true
|
||||||
CYPRESS_CI: true
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
merge_group:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -15,22 +16,31 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v3
|
||||||
id: yarn-and-build-cache
|
id: pnpm-and-build-cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
|
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node_modules-build-
|
${{ runner.os }}-node_modules-build-
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- name: Install pnpm
|
||||||
with:
|
uses: pnpm/action-setup@v4
|
||||||
node-version: 18
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Lint & Test
|
- uses: actions/setup-node@v4
|
||||||
run: |
|
with:
|
||||||
yarn install
|
node-version-file: '.node-version'
|
||||||
yarn lint
|
cache: 'pnpm'
|
||||||
yarn test:unit
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm check
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: pnpm test:unit
|
||||||
|
|||||||
+13
-9
@@ -1,14 +1,18 @@
|
|||||||
node_modules/
|
|
||||||
coverage/
|
|
||||||
.cache/
|
|
||||||
build/
|
|
||||||
yarn-error.log
|
|
||||||
.npmrc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.cache/
|
||||||
|
.env.local
|
||||||
|
.npmrc
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
/coverage
|
||||||
|
/docs
|
||||||
/functions
|
/functions
|
||||||
|
/node_modules
|
||||||
/snapshots.js
|
/snapshots.js
|
||||||
/cypress/downloads
|
|
||||||
/cypress/videos
|
# Playwright
|
||||||
/cypress/screenshots
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
yarn pre-commit
|
pnpm pre-commit
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
20.14.0
|
24.16.0
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ node_modules/**
|
|||||||
coverage/**
|
coverage/**
|
||||||
__snapshots__/**
|
__snapshots__/**
|
||||||
snapshots.js
|
snapshots.js
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
+2
-2
@@ -4,6 +4,6 @@
|
|||||||
"bracketSameLine": true,
|
"bracketSameLine": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"tailwindConfig": "./tailwind.config.cjs",
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
|
"tailwindStylesheet": "./src/app.css"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"svelte": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@sveltejs/mcp"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputs": []
|
||||||
|
}
|
||||||
Vendored
+30
-4
@@ -2,32 +2,58 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"cSpell.blockCheckingWhenLineLengthGreaterThan": 150,
|
"cSpell.blockCheckingWhenLineLengthGreaterThan": 150,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"appinstalled",
|
||||||
"asyncable",
|
"asyncable",
|
||||||
"Browserslist",
|
"Browserslist",
|
||||||
"ckppp",
|
"ckppp",
|
||||||
"corg",
|
"corg",
|
||||||
"cssnano",
|
"cssnano",
|
||||||
"daisyui",
|
|
||||||
"esserializer",
|
"esserializer",
|
||||||
|
"fsegurai",
|
||||||
"gantt",
|
"gantt",
|
||||||
"gitgraph",
|
"gitgraph",
|
||||||
|
"hugeicons",
|
||||||
"KROKI",
|
"KROKI",
|
||||||
"localstorage",
|
"localstorage",
|
||||||
"mermaidchart",
|
"mermaidchart",
|
||||||
"mindmap",
|
"mindmap",
|
||||||
|
"NEWYEAR",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer",
|
||||||
"Pageview",
|
"Pageview",
|
||||||
"pako",
|
"pako",
|
||||||
|
"panmove",
|
||||||
|
"panstart",
|
||||||
"panzoom",
|
"panzoom",
|
||||||
|
"pinchmove",
|
||||||
|
"pinchstart",
|
||||||
"pzoom",
|
"pzoom",
|
||||||
"roughjs",
|
"roughjs",
|
||||||
|
"sankey",
|
||||||
"Serde",
|
"Serde",
|
||||||
"serdes",
|
"serdes",
|
||||||
|
"Stackable",
|
||||||
"tailwindcss",
|
"tailwindcss",
|
||||||
"uparrow"
|
"treemap",
|
||||||
|
"uparrow",
|
||||||
|
"xychart",
|
||||||
|
"zenuml"
|
||||||
],
|
],
|
||||||
"vitest.commandLine": "yarn test:unit",
|
"vitest.commandLine": "pnpm test:unit",
|
||||||
"vitest.enable": true,
|
"vitest.enable": true,
|
||||||
"testing.autoRun.mode": "rerun",
|
"testing.autoRun.mode": "rerun",
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"githubPullRequests.ignoredPullRequestBranches": ["develop"]
|
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
|
||||||
|
"[svelte]": {
|
||||||
|
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||||
|
},
|
||||||
|
"tailwindCSS.classAttributes": ["class", "className", ".*Classes"],
|
||||||
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||||
|
],
|
||||||
|
"tailwindCSS.emmetCompletions": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -1,16 +1,16 @@
|
|||||||
FROM docker.io/library/node:20-alpine3.18 AS mermaid-live-editor-dependencies
|
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies
|
||||||
|
|
||||||
RUN apk --no-cache add build-base git python3 && \
|
RUN apk --no-cache add build-base git python3 && \
|
||||||
rm -rf /var/cache/apk/*
|
rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
RUN yarn global add node-gyp
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ./package.json .
|
COPY ./package.json .
|
||||||
COPY ./yarn.lock .
|
COPY ./pnpm-lock.yaml .
|
||||||
|
|
||||||
RUN yarn install
|
RUN pnpm install
|
||||||
|
|
||||||
FROM mermaid-live-editor-dependencies AS mermaid-live-editor-builder
|
FROM mermaid-live-editor-dependencies AS mermaid-live-editor-builder
|
||||||
|
|
||||||
@@ -18,16 +18,20 @@ ARG MERMAID_RENDERER_URL
|
|||||||
ARG MERMAID_KROKI_RENDERER_URL
|
ARG MERMAID_KROKI_RENDERER_URL
|
||||||
ARG MERMAID_ANALYTICS_URL
|
ARG MERMAID_ANALYTICS_URL
|
||||||
ARG MERMAID_DOMAIN
|
ARG MERMAID_DOMAIN
|
||||||
|
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
|
||||||
|
ARG MERMAID_PRIVACY_POLICY_URL
|
||||||
|
ARG MERMAID_HIDE_PRIVACY_POLICY
|
||||||
|
ARG MERMAID_BASE_PATH
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
RUN yarn build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM mermaid-live-editor-builder AS mermaid-dev
|
FROM mermaid-live-editor-builder AS mermaid-dev
|
||||||
|
|
||||||
ENTRYPOINT ["yarn", "dev"]
|
ENTRYPOINT ["pnpm", "dev"]
|
||||||
|
|
||||||
FROM nginx:1.25-alpine3.18 AS mermaid
|
FROM nginx:1.28-alpine3.21 AS mermaid
|
||||||
|
|
||||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=mermaid-live-editor-builder /app/docs /usr/share/nginx/html
|
COPY --from=mermaid-live-editor-builder /app/docs /usr/share/nginx/html
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[](https://dashboard.cypress.io/projects/2ckppp/runs) [](https://join.slack.com/t/mermaid-talk/shared_invite/enQtNzc4NDIyNzk4OTAyLWVhYjQxOTI2OTg4YmE1ZmJkY2Y4MTU3ODliYmIwOTY3NDJlYjA0YjIyZTdkMDMyZTUwOGI0NjEzYmEwODcwOTE) [](https://app.netlify.com/sites/mermaidjs/deploys)
|
[](https://discord.gg/sKeNQX4Wtj)
|
||||||
|
[](https://app.netlify.com/sites/mermaidjs/deploys)
|
||||||
|
|
||||||
# Contributors are welcome!
|
# Mermaid Live Editor
|
||||||
|
|
||||||
If you want to speed up the progress for mermaid-live-editor, join the slack channel and contact knsv.
|
|
||||||
|
|
||||||
# mermaid-live-editor
|
|
||||||
|
|
||||||
Edit, preview and share mermaid charts/diagrams.
|
Edit, preview and share mermaid charts/diagrams.
|
||||||
|
|
||||||
@@ -17,7 +14,11 @@ Edit, preview and share mermaid charts/diagrams.
|
|||||||
|
|
||||||
## Live demo
|
## Live demo
|
||||||
|
|
||||||
You can try out a live version [here](https://mermaid.live/).
|
You can try out a [live version](https://mermaid.live/).
|
||||||
|
|
||||||
|
# Contributors are welcome!
|
||||||
|
|
||||||
|
If you want to speed up the progress for mermaid-live-editor, join the Discord channel and contact knsv.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -27,15 +28,22 @@ You can try out a live version [here](https://mermaid.live/).
|
|||||||
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
|
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The published docker image is built using our default environment variables. You cannot override them when running the image. If you need to customize them, you will need to build the image yourself.
|
||||||
|
|
||||||
### To configure renderer URL
|
### To configure renderer URL
|
||||||
|
|
||||||
When building set the MERMAID_RENDERER_URL build argument to the rendering service.
|
When building set the MERMAID_RENDERER_URL build argument to the rendering
|
||||||
Default is `https://mermaid.ink`
|
service.
|
||||||
|
Example:
|
||||||
|
Default is`https://mermaid.ink`.
|
||||||
|
Set to empty string to disable PNG and SVG links under Actions
|
||||||
|
|
||||||
### To configure Kroki Instance URL
|
### To configure Kroki Instance URL
|
||||||
|
|
||||||
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki instance.
|
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki
|
||||||
|
instance.
|
||||||
Default is `https://kroki.io`
|
Default is `https://kroki.io`
|
||||||
|
Set to empty string to disable Kroki link under Actions
|
||||||
|
|
||||||
### To configure Analytics
|
### To configure Analytics
|
||||||
|
|
||||||
@@ -43,6 +51,18 @@ When building set the MERMAID_ANALYTICS_URL build argument to your plausible ins
|
|||||||
|
|
||||||
Default is empty, disabling analytics.
|
Default is empty, disabling analytics.
|
||||||
|
|
||||||
|
### To enable Mermaid Chart links and promotion
|
||||||
|
|
||||||
|
When building set the MERMAID_IS_ENABLED_MERMAID_CHART_LINKS build argument to `true`
|
||||||
|
|
||||||
|
Default is empty, disabling button to save to Mermaid Chart and promotional banner.
|
||||||
|
|
||||||
|
### To update the Security modal
|
||||||
|
|
||||||
|
The modal shown on clicking the security link assumes analytics, renderer, Kroki
|
||||||
|
and Mermaid chart are enabled. You can update it by modifying `Privacy.svelte`
|
||||||
|
if you wish.
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,15 +101,14 @@ https://docs.github.com/en/get-started/quickstart/fork-a-repo
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [volta](https://volta.sh/) to manage node versions.
|
- [Node.js](https://nodejs.org/en/) current LTS version
|
||||||
- [Node.js](https://nodejs.org/en/). `volta install node`
|
- [pnpm](https://pnpm.io/) package manager. Install with `corepack enable pnpm`
|
||||||
- [yarn](https://yarnpkg.com/) package manager. `volta install yarn`
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn install
|
pnpm install
|
||||||
yarn dev -- --open
|
pnpm dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
This app is created with Svelte Kit.
|
This app is created with Svelte Kit.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
# git clone https://github.com/mermaid-js/docs.git
|
# git clone https://github.com/mermaid-js/docs.git
|
||||||
set -e
|
set -e
|
||||||
rm -rf docs
|
rm -rf docs
|
||||||
yarn release
|
pnpm release
|
||||||
pushd .
|
pushd .
|
||||||
if [ ! -d ../docs ]; then
|
if [ ! -d ../docs ]; then
|
||||||
echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing."
|
echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing."
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "slate"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { defineConfig } from 'cypress';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
export default defineConfig({
|
|
||||||
projectId: '2ckppp',
|
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 768,
|
|
||||||
snapshotFileName: './cypress/snapshots.js',
|
|
||||||
defaultCommandTimeout: 5000,
|
|
||||||
requestTimeout: 5000,
|
|
||||||
retries: {
|
|
||||||
runMode: 2,
|
|
||||||
openMode: 0
|
|
||||||
},
|
|
||||||
e2e: {
|
|
||||||
setupNodeEvents(on, config) {
|
|
||||||
on('task', {
|
|
||||||
readAndDeleteFile({ fileNamePattern, folder, mode }) {
|
|
||||||
const fileNameRegex = new RegExp(fileNamePattern);
|
|
||||||
const files = fs.readdirSync(folder);
|
|
||||||
const filename = files.find((file) => file.match(fileNameRegex));
|
|
||||||
const filePath = path.join(folder, filename);
|
|
||||||
try {
|
|
||||||
if (mode === 'size') {
|
|
||||||
return fs.statSync(filePath).size;
|
|
||||||
}
|
|
||||||
return fs.readFileSync(filePath, 'utf8');
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
baseUrl: 'http://localhost:3000',
|
|
||||||
specPattern: 'cypress/e2e/**/*.spec.ts'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": ["cypress"],
|
|
||||||
"extends": ["plugin:cypress/recommended"],
|
|
||||||
"rules": {
|
|
||||||
"jest/expect-expect": "off"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"cypress/globals": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { typeInEditor, verifyFileSizeGreaterThan } from './util';
|
|
||||||
describe('Check actions', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/edit');
|
|
||||||
cy.contains('Actions').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update markdown code', () => {
|
|
||||||
cy.get('#markdown')
|
|
||||||
.invoke('val')
|
|
||||||
.then((oldText) => {
|
|
||||||
typeInEditor('C --> HistoryTest', { bottom: true, newline: true });
|
|
||||||
cy.get('#markdown')
|
|
||||||
.invoke('val')
|
|
||||||
.then((newText) => {
|
|
||||||
expect(oldText).to.not.eq(newText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should load gists from URL', () => {
|
|
||||||
cy.get('#gist').type('https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a');
|
|
||||||
cy.contains('Load Gist').click();
|
|
||||||
cy.contains('Go shopping!!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should download png and svg', () => {
|
|
||||||
cy.get(`#downloadPNG`).click();
|
|
||||||
verifyFileSizeGreaterThan('diagram', 'png', 34_000);
|
|
||||||
|
|
||||||
cy.get(`#downloadSVG`).click();
|
|
||||||
verifyFileSizeGreaterThan('diagram', 'svg', 10_000);
|
|
||||||
|
|
||||||
// Verify downloaded file is different for different diagrams
|
|
||||||
cy.contains('Sample Diagrams').click();
|
|
||||||
cy.contains('ER').click();
|
|
||||||
|
|
||||||
cy.get(`#downloadPNG`).click();
|
|
||||||
verifyFileSizeGreaterThan('diagram', 'png', 40_000);
|
|
||||||
|
|
||||||
cy.get(`#downloadSVG`).click();
|
|
||||||
verifyFileSizeGreaterThan('diagram', 'svg', 11_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { typeInEditor, cmd } from './util';
|
|
||||||
|
|
||||||
describe('Auto sync tests', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/');
|
|
||||||
cy.url().should('contain', '/edit#pako');
|
|
||||||
cy.window().should('have.property', 'editorLoaded', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should dim diagram when code is edited', () => {
|
|
||||||
cy.contains('Auto sync').click();
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
cy.get('#errorContainer').should('not.exist');
|
|
||||||
typeInEditor(' C --> Test', { bottom: true });
|
|
||||||
cy.get('#view').should('have.class', 'outOfSync');
|
|
||||||
cy.get('#errorContainer').should('contain.text', 'Diagram out of sync.');
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update diagram when shortcut is used', () => {
|
|
||||||
cy.contains('Auto sync').click();
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
typeInEditor(' C --> Test');
|
|
||||||
cy.get('#view').should('have.class', 'outOfSync');
|
|
||||||
cy.get('#errorContainer').should('contain.text', 'Diagram out of sync.');
|
|
||||||
typeInEditor(`${cmd}{enter}`);
|
|
||||||
cy.get('#errorContainer').should('not.exist');
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show/hide sync button with auto sync', () => {
|
|
||||||
cy.get('[data-cy=sync]').should('not.exist');
|
|
||||||
cy.contains('Auto sync').click();
|
|
||||||
cy.get('[data-cy=sync]').should('exist');
|
|
||||||
cy.get('#autoSync').check();
|
|
||||||
cy.get('[data-cy=sync]').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not dim diagram when code is in sync', () => {
|
|
||||||
cy.contains('Auto sync').click();
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
typeInEditor(' C --> Test');
|
|
||||||
cy.get('#view').should('have.class', 'outOfSync');
|
|
||||||
cy.get('[data-cy=sync]').click();
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
cy.get('#autoSync').check();
|
|
||||||
typeInEditor('ing');
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should automatically defer rendering when complex diagrams are edited', () => {
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
typeInEditor(`
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i
|
|
||||||
A & B & C & D & E --> F & G & K & Z & i`);
|
|
||||||
cy.get('#view').should('have.class', 'outOfSync');
|
|
||||||
cy.get('#errorContainer').should('contain.text', 'It will be updated automatically.');
|
|
||||||
// The class should be removed automatically after 1 second.
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports commenting code out/in', () => {
|
|
||||||
cy.get('#editor').contains('Car').click({ force: true });
|
|
||||||
cy.get('#editor').get('textarea').type(`${cmd}/`, { force: true });
|
|
||||||
cy.get('#view').contains('Car').should('not.exist');
|
|
||||||
|
|
||||||
typeInEditor(`{uparrow}${cmd}/`);
|
|
||||||
cy.get('#view').contains('Car').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports editing code when code is incorrect', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit#pako:eNpljjEKwzAMRa8SNOcEnlt6gK5eVFvYJsgOqkwpIXevg9smEE1PnyfxF3DFExgISW-CczQ2D21cYU7a-SGYXRwyvTp9jUhuKlVP-eHy7zA-leQsMEmg_QOM0BLG5FujZVMsaCQmC6ahR5ks2Lw2r84ela4-aREwKpVGwKrl_s7ut3fnkjAIcg_XDzuaUhs'
|
|
||||||
);
|
|
||||||
cy.get('#errorContainer').should('not.exist');
|
|
||||||
typeInEditor(`branch test`, { bottom: true, newline: true });
|
|
||||||
cy.get('#editor').contains('branch test').should('exist');
|
|
||||||
cy.get('#errorContainer')
|
|
||||||
.contains(
|
|
||||||
'Error: Trying to checkout branch which is not yet created. (Help try using "branch master")'
|
|
||||||
)
|
|
||||||
.should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update diagram after entire text is removed', () => {
|
|
||||||
// https://github.com/mermaid-js/mermaid-live-editor/issues/1102
|
|
||||||
typeInEditor(`${cmd} a {backspace}`);
|
|
||||||
typeInEditor('graph LR');
|
|
||||||
typeInEditor(' {enter} A-->Car');
|
|
||||||
cy.get('#view').contains('Car').should('exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Pan and Zoom', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/');
|
|
||||||
});
|
|
||||||
it('should toggle pan and zoom', () => {
|
|
||||||
cy.get('#svg-pan-zoom-reset-pan-zoom').should('not.exist');
|
|
||||||
cy.contains('Pan & Zoom').click();
|
|
||||||
cy.get('#svg-pan-zoom-reset-pan-zoom').should('exist');
|
|
||||||
cy.contains('Pan & Zoom').click();
|
|
||||||
cy.get('#svg-pan-zoom-reset-pan-zoom').should('not.exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
describe('Editor docs tests', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.on('uncaught:exception', () => {
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/edit');
|
|
||||||
cy.contains('Sample Diagrams').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test default loading', () => {
|
|
||||||
cy.get(`[data-cy=docs][href^="https://mermaid.js.org/"]`).should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test to see if the correct URL loads when changing from one diagram to other', () => {
|
|
||||||
cy.contains('Flow').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/flowchart.html"]`).should('exist');
|
|
||||||
|
|
||||||
cy.contains('Config').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/flowchart.html#configuration"]`).should('exist');
|
|
||||||
|
|
||||||
cy.contains('Sequence').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/sequenceDiagram.html#configuration"]`).should('exist');
|
|
||||||
|
|
||||||
cy.contains('Code').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/sequenceDiagram.html"]`).should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Test to check URLs for a case where config URL doesn't exist", () => {
|
|
||||||
cy.contains('State').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/stateDiagram.html"]`).should('exist');
|
|
||||||
|
|
||||||
cy.contains('Config').click();
|
|
||||||
cy.get(`[data-cy=docs][href$="/syntax/stateDiagram.html"]`).should('exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { typeInEditor, verifyFileSnapshot } from './util';
|
|
||||||
|
|
||||||
describe('Save History', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clock(new Date(2022, 0, 1).getTime());
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/edit');
|
|
||||||
|
|
||||||
cy.contains('History').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cy.clock().invoke('restore');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load history from localstorage', () => {
|
|
||||||
cy.setLocalStorage(
|
|
||||||
'manualHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]'
|
|
||||||
);
|
|
||||||
cy.setLocalStorage(
|
|
||||||
'autoHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]'
|
|
||||||
);
|
|
||||||
cy.reload();
|
|
||||||
cy.contains('History').click();
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 2);
|
|
||||||
cy.get('#historyList').find('No items in History').should('not.exist');
|
|
||||||
cy.get('#historyList').contains('helpful-ocean');
|
|
||||||
cy.get('#historyList').contains('hollow-art');
|
|
||||||
cy.contains('Restore').click();
|
|
||||||
cy.contains('Halloween');
|
|
||||||
cy.contains('Timeline').click();
|
|
||||||
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 2);
|
|
||||||
cy.get('#historyList').find('No items in History').should('not.exist');
|
|
||||||
cy.get('#historyList').contains('needy-mosquito');
|
|
||||||
cy.get('#historyList').contains('barking-dog');
|
|
||||||
cy.contains('Restore').click();
|
|
||||||
cy.contains('New Year');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save when clicked', () => {
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 0);
|
|
||||||
cy.get('#historyList').contains('No items in History');
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
cy.get('#historyList').find('No items in History').should('not.exist');
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 1);
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
cy.on('window:alert', (str) => {
|
|
||||||
expect(str).to.equal('State already saved.');
|
|
||||||
});
|
|
||||||
cy.on('window:confirm', () => true);
|
|
||||||
typeInEditor(' C --> HistoryTest');
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be able to restore and delete', () => {
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
typeInEditor(' C --> HistoryTest');
|
|
||||||
cy.get('#historyList').find('No items in History').should('not.exist');
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 1);
|
|
||||||
cy.contains('HistoryTest');
|
|
||||||
cy.contains('Restore').click();
|
|
||||||
cy.contains('HistoryTest').should('not.exist');
|
|
||||||
cy.contains('Delete').click();
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 0);
|
|
||||||
cy.get('#historyList').contains('No items in History');
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
typeInEditor(' C --> HistoryTest');
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
cy.get('#editor').type('ing');
|
|
||||||
cy.get('#clearHistory').click();
|
|
||||||
cy.on('window:alert', (str) => {
|
|
||||||
expect(str).to.equal('Clear all saved items?');
|
|
||||||
});
|
|
||||||
cy.on('window:confirm', () => true);
|
|
||||||
cy.get('#historyList').contains('No items in History');
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Fix #639
|
|
||||||
xit('should auto save history', () => {
|
|
||||||
typeInEditor(' C --> HistoryTest');
|
|
||||||
cy.tick(70_000);
|
|
||||||
cy.contains('Timeline').click();
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 1);
|
|
||||||
cy.get('#editor').type('ing');
|
|
||||||
cy.tick(70_000);
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 2);
|
|
||||||
for (let i = 0; i < 31; i++) {
|
|
||||||
cy.get('#editor').type('.');
|
|
||||||
cy.tick(70_000);
|
|
||||||
}
|
|
||||||
cy.get('#historyList').find('li').should('have.length', 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should download history', () => {
|
|
||||||
cy.get('#saveHistory').click();
|
|
||||||
cy.get(`#downloadHistory`).click();
|
|
||||||
verifyFileSnapshot('history', 'json', 'A[Christmas] -->|Get money| B(Go shopping)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { toBase64 } from 'js-base64';
|
|
||||||
|
|
||||||
describe('Site Loads', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/');
|
|
||||||
cy.url().should('include', '/edit#pako');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Check Home page load', () => {
|
|
||||||
cy.url().should('include', '/edit');
|
|
||||||
cy.contains('History').click();
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep code after reload', () => {
|
|
||||||
cy.get('#editor').contains('Car');
|
|
||||||
cy.reload();
|
|
||||||
cy.get('#editor').contains('Car');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Check Redirect from old URL', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZylcbiAgICBCIC0tPiBDe0xldCBtZSB0aGlua31cbiAgICBDIC0tPnxPbmV8IERbTGFwdG9wXVxuICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdXG4gICAgQyAtLT58VGhyZWV8IEZbZmE6ZmEtY2FyIENhcl0iLCJtZXJtYWlkIjp7InRoZW1lIjoiZGVmYXVsdCJ9LCJ1cGRhdGVFZGl0b3IiOmZhbHNlfQ'
|
|
||||||
);
|
|
||||||
cy.url().should('include', '/edit#pako:eNp');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load sample diagrams when clicked', () => {
|
|
||||||
cy.contains('Sample Diagrams').click();
|
|
||||||
cy.contains('Pie').click();
|
|
||||||
cy.contains('pie title Pets adopted by volunteers');
|
|
||||||
cy.contains('Class').click();
|
|
||||||
cy.contains('classDiagram');
|
|
||||||
});
|
|
||||||
|
|
||||||
(Cypress.env('CI') === 'true' ? describe : describe.skip)('github', () => {
|
|
||||||
it('should load diagram from gist', () => {
|
|
||||||
cy.visit(`/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a`);
|
|
||||||
cy.contains('History').click();
|
|
||||||
cy.contains('Go shopping!!');
|
|
||||||
cy.contains('Revisions');
|
|
||||||
cy.contains('sidharthv96 v8f8f1e2');
|
|
||||||
cy.contains('sidharthv96 v7851e19');
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load diagram from gist revision', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19'
|
|
||||||
);
|
|
||||||
cy.contains('History').click();
|
|
||||||
cy.contains('Party');
|
|
||||||
cy.contains('Revisions');
|
|
||||||
cy.contains('sidharthv96 v7851e19');
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load diagram from raw files', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit?code=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd&config=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json'
|
|
||||||
);
|
|
||||||
cy.contains('Party');
|
|
||||||
cy.getLocalStorage('codeStore').snapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disabled temporarily. Should be enabled after the issue is fixed in Mermaid.
|
|
||||||
// it('should prevent setting the "securityLevel" option via URL', () => {
|
|
||||||
// const b64State = toBase64(
|
|
||||||
// `{"code":"graph TD\\nA[\\"<img src='https://via.placeholder.com/64' width=64 />\\"]","mermaid":"{\\"securityLevel\\": \\"loose\\", \\"theme\\": \\"forest\\"}","autoSync":true,"updateDiagram":true}`,
|
|
||||||
// true
|
|
||||||
// );
|
|
||||||
// cy.on('window:confirm', () => true);
|
|
||||||
// cy.visit(`/edit#${b64State}`);
|
|
||||||
// cy.contains('Config').click();
|
|
||||||
// cy.contains('forest');
|
|
||||||
// cy.contains('securityLevel').should('not.exist');
|
|
||||||
// cy.get('#view').find('img').should('not.exist');
|
|
||||||
// cy.get('#view').contains('<img');
|
|
||||||
// cy.get('#view').contains(`src='https://via.placeholder.com/64'`);
|
|
||||||
// });
|
|
||||||
|
|
||||||
it.skip('should allow persisting "securityLevel" using confirm dialogue', () => {
|
|
||||||
const b64State = toBase64(
|
|
||||||
`{"code":"graph TD\\nA[\\"<img src='https://dummyimage.com/64' width=64/>\\"]","mermaid":"{\\"securityLevel\\": \\"loose\\", \\"theme\\": \\"forest\\"}","autoSync":true,"updateDiagram":true}`,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
cy.on('window:confirm', () => false);
|
|
||||||
cy.visit(`/edit#${b64State}`);
|
|
||||||
cy.get('#editor').type(' ');
|
|
||||||
cy.contains('Config').click();
|
|
||||||
cy.contains('forest');
|
|
||||||
cy.contains('securityLevel');
|
|
||||||
cy.get('#view').find('img').should('be.visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show troubleshooting steps if loading fails', () => {
|
|
||||||
cy.visit('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
|
|
||||||
cy.reload(true);
|
|
||||||
cy.contains('Please Click here to Raise an issue in github.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Verify types of URLs', () => {
|
|
||||||
it('should load compressed URL', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit#pako:eNpVkM2KwkAQhF-l6dMK5gVyEDRxvYi7sF6WjIcm0zqDzg_jBJEk725Hd2G3Tw31VVFUj23QjCWeEkUD-1p5kFs2O77BN1M6QFEshg1ncMHzfYDV2ybA1YQYrT_NXvhqgqDqtxPGkI315_ElVU__h-cB6mZLMYd4-Kvsb2GAdWM_jcT_V0xicb03RyqPVLSUoJI-OEfHyZHV0rqfDAqzYccKS3k1pbNC5Ufhuqgp81rbHBJKxuXKc6Quh6-7b7HMqeNfqLYkC7gfanwAlW1ZvQ'
|
|
||||||
);
|
|
||||||
cy.contains('New Year');
|
|
||||||
cy.visit(
|
|
||||||
'/edit#pako:eNptkU1PwzAMhv9K5BOI9Q9EXBDbJA477YYqITcxndV8QD40weh_Jy1rGR0-OY_tV2_sEyivCSQogzGuGduAtnaixINji0bcf1WVWGfVXdMtx8M1faYm4B8sxR27JLClJd6nwK4VLTlN4bI4jMQd2pLe3C4KFhNNcLQ92jv9ADGLNoTdozc-zIV4ZDsNlud7RtVN7_5Sb_jYrFcN3iN_0pPbEqUZK3QbTP_Ojyv4NdR4bwTHlyMbPcOQ3WJ2CliBpWCRdbnLqFJDOpClGmRJNYauhtr1pS-_6bKMjebkA8hXNJFWgDn5_YdTIFPINDWdb3vu6r8BaWOZRQ'
|
|
||||||
);
|
|
||||||
cy.contains('Animal');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Uncompressed URLs', () => {
|
|
||||||
it('should load URL without specifier', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit/#eyJjb2RlIjoiZ3JhcGhcbiAgICBUZXN0TGFiZWwiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGVmYXVsdFwiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
|
|
||||||
);
|
|
||||||
cy.contains('TestLabel');
|
|
||||||
cy.visit(
|
|
||||||
'/edit#eyJjb2RlIjoiY2xhc3NEaWFncmFtXG4gICAgQW5pbWFsIDx8LS0gRHVja1xuICAgIEFuaW1hbCA8fC0tIEZpc2hcbiAgICBBbmltYWwgPHwtLSBaZWJyYVxuICAgIEFuaW1hbCA6ICtpbnQgYWdlXG4gICAgQW5pbWFsIDogK1N0cmluZyBnZW5kZXJcbiAgICBBbmltYWw6ICtpc01hbW1hbCgpXG4gICAgQW5pbWFsOiArbWF0ZSgpXG4gICAgY2xhc3MgRHVja3tcbiAgICAgICtTdHJpbmcgYmVha0NvbG9yXG4gICAgICArc3dpbSgpXG4gICAgICArcXVhY2soKVxuICAgIH1cbiAgICBjbGFzcyBGaXNoe1xuICAgICAgLWludCBzaXplSW5GZWV0XG4gICAgICAtY2FuRWF0KClcbiAgICB9XG4gICAgY2xhc3MgWmVicmF7XG4gICAgICArYm9vbCBpc193aWxkXG4gICAgICArcnVuKClcbiAgICB9XG4gICAgICAgICAgICAiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGFya1wiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
|
|
||||||
);
|
|
||||||
cy.contains('Animal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load URL with "base64" specifier', () => {
|
|
||||||
cy.visit(
|
|
||||||
'/edit/#base64:eyJjb2RlIjoiZ3JhcGhcbiAgICBUZXN0TGFiZWwiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGVmYXVsdFwiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
|
|
||||||
);
|
|
||||||
cy.contains('TestLabel');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
describe('Test themes', () => {
|
|
||||||
describe('Test light themes', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/edit', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
cy.stub(win, 'matchMedia')
|
|
||||||
.callThrough()
|
|
||||||
.withArgs('(prefers-color-scheme: dark)')
|
|
||||||
.returns({
|
|
||||||
matches: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cy.contains('Theme').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set light theme as default', () => {
|
|
||||||
cy.contains('light').parent().should('have.class', 'bordered');
|
|
||||||
cy.contains('dark').parent().should('not.have.class', 'bordered');
|
|
||||||
cy.getLocalStorage('themeStore').snapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should change themes when clicked', () => {
|
|
||||||
cy.contains('light').parent().should('have.class', 'bordered');
|
|
||||||
cy.contains('cupcake').click();
|
|
||||||
cy.contains('cupcake').parent().should('have.class', 'bordered');
|
|
||||||
cy.contains('light').parent().should('not.have.class', 'bordered');
|
|
||||||
cy.contains('dark').parent().should('not.have.class', 'bordered');
|
|
||||||
cy.getLocalStorage('themeStore').snapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Test dark mode', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit('/edit', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
cy.stub(win, 'matchMedia')
|
|
||||||
.callThrough()
|
|
||||||
.withArgs('(prefers-color-scheme: dark)')
|
|
||||||
.returns({
|
|
||||||
matches: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cy.contains('Theme').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set dark theme as default', () => {
|
|
||||||
cy.contains('light').parent().should('not.have.class', 'bordered');
|
|
||||||
cy.contains('dark').parent().should('have.class', 'bordered');
|
|
||||||
cy.getLocalStorage('themeStore').snapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
export const cmd = `{${Cypress.platform === 'darwin' ? 'meta' : 'ctrl'}}`;
|
|
||||||
|
|
||||||
interface EditorOptions {
|
|
||||||
bottom?: boolean;
|
|
||||||
newline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const typeInEditor = (
|
|
||||||
text: string,
|
|
||||||
{ bottom = true, newline = false }: EditorOptions = {}
|
|
||||||
) => {
|
|
||||||
cy.window().should('have.property', 'editorLoaded', true);
|
|
||||||
cy.get('#editor').click();
|
|
||||||
cy.get('#editor').within(() => {
|
|
||||||
if (bottom) {
|
|
||||||
cy.get('textarea').type('{pageDown}', { force: true });
|
|
||||||
}
|
|
||||||
if (newline) {
|
|
||||||
cy.get('textarea').type('{enter}', { force: true });
|
|
||||||
}
|
|
||||||
cy.get('textarea').type(text, { force: true });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadsFolder = Cypress.config('downloadsFolder');
|
|
||||||
|
|
||||||
export const verifyFileSizeGreaterThan = (
|
|
||||||
fileType: 'history' | 'diagram',
|
|
||||||
extension: string,
|
|
||||||
size: number
|
|
||||||
) => {
|
|
||||||
cy.get('#view').should('not.have.class', 'outOfSync');
|
|
||||||
cy.task('readAndDeleteFile', {
|
|
||||||
folder: downloadsFolder,
|
|
||||||
fileNamePattern: `^mermaid-${fileType}-.*.${extension}$`,
|
|
||||||
mode: 'size'
|
|
||||||
}).then((fileSize: number) => {
|
|
||||||
expect(fileSize).to.be.gt(size);
|
|
||||||
expect(fileSize).to.be.lt(size * 1.3);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const verifyFileSnapshot = (
|
|
||||||
fileType: 'history' | 'diagram',
|
|
||||||
extension: string,
|
|
||||||
content: string
|
|
||||||
) => {
|
|
||||||
cy.task('readAndDeleteFile', {
|
|
||||||
folder: downloadsFolder,
|
|
||||||
fileNamePattern: `^mermaid-${fileType}-.*.${extension}$`,
|
|
||||||
mode: 'content'
|
|
||||||
}).then((fileContent: number) => {
|
|
||||||
expect(fileContent).to.contain(content);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Using fixtures to represent data",
|
|
||||||
"email": "hello@cypress.io",
|
|
||||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
"Site Loads": {
|
|
||||||
"Check Home page load": {
|
|
||||||
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
|
|
||||||
},
|
|
||||||
"Check Redirect from old URL": {
|
|
||||||
"1": "{\"code\":\"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
|
|
||||||
},
|
|
||||||
"should load diagram from gist": {
|
|
||||||
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a\"}}}"
|
|
||||||
},
|
|
||||||
"should load diagram from gist revision": {
|
|
||||||
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19\"}}}"
|
|
||||||
},
|
|
||||||
"should load diagram from raw files": {
|
|
||||||
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"files\",\"config\":{\"codeURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd\",\"configURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json\"}}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"__version": "12.17.4",
|
|
||||||
"Auto sync tests": {
|
|
||||||
"should dim diagram when code is edited": {
|
|
||||||
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Test\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":false,\"updateDiagram\":false}"
|
|
||||||
},
|
|
||||||
"should not dim diagram when code is in sync": {
|
|
||||||
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Testing\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":false}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Test themes": {
|
|
||||||
"should set light theme as default": {
|
|
||||||
"1": "{\"theme\":\"light\",\"isDark\":false}"
|
|
||||||
},
|
|
||||||
"should change themes when clicked": {
|
|
||||||
"1": "{\"theme\":\"cupcake\",\"isDark\":false}"
|
|
||||||
},
|
|
||||||
"should set dark theme as default": {
|
|
||||||
"1": "{\"theme\":\"dark\",\"isDark\":true}"
|
|
||||||
},
|
|
||||||
"Test light themes": {
|
|
||||||
"should set light theme as default": {
|
|
||||||
"1": "{\"theme\":\"light\",\"isDark\":false}"
|
|
||||||
},
|
|
||||||
"should change themes when clicked": {
|
|
||||||
"1": "{\"theme\":\"cupcake\",\"isDark\":false}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Test dark mode": {
|
|
||||||
"should set dark theme as default": {
|
|
||||||
"1": "{\"theme\":\"dark\",\"isDark\":true}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// ***********************************************
|
|
||||||
// This example commands.js shows you how to
|
|
||||||
// create various custom commands and overwrite
|
|
||||||
// existing commands.
|
|
||||||
//
|
|
||||||
// For more comprehensive examples of custom
|
|
||||||
// commands please read more here:
|
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a parent command --
|
|
||||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a child command --
|
|
||||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a dual command --
|
|
||||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This will overwrite an existing command --
|
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
|
||||||
import { register } from '@cypress/snapshot';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
register();
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
snapshot(): void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import 'cypress-localstorage-commands';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// This example support/index.js is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import './commands';
|
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
|
||||||
// require('./commands')
|
|
||||||
|
|
||||||
Cypress.on('uncaught:exception', (err) => {
|
|
||||||
/* returning false here prevents Cypress from failing the test */
|
|
||||||
if (err.message.includes('ResizeObserver loop limit exceeded')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"types": ["cypress", "cypress-localstorage-commands", "node"]
|
|
||||||
},
|
|
||||||
"include": ["**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import sortKeysPlugin from 'eslint-plugin-sort-keys';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.strict,
|
||||||
|
...ts.configs.stylistic,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
unicorn: eslintPluginUnicorn
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'unicorn/no-null': 'off',
|
||||||
|
'unicorn/filename-case': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**'],
|
||||||
|
plugins: {
|
||||||
|
'sort-keys': sortKeysPlugin
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'sort-keys/sort-keys-fix': ['error', 'asc', { minKeys: 5 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'svelte/no-unused-props': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/components/ui/**'],
|
||||||
|
rules: {
|
||||||
|
'unicorn/prefer-export-from': 'off',
|
||||||
|
'unicorn/prevent-abbreviations': 'off',
|
||||||
|
'unicorn/explicit-length-check': 'off',
|
||||||
|
'sort-keys/sort-keys-fix': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
[build.environment]
|
[build.environment]
|
||||||
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
|
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
|
||||||
|
MERMAID_DOCS_URL = 'https://mermaid.js.org'
|
||||||
MERMAID_DOMAIN = 'mermaid.live'
|
MERMAID_DOMAIN = 'mermaid.live'
|
||||||
|
MERMAID_RENDERER_URL = 'https://mermaid.ink'
|
||||||
|
MERMAID_KROKI_RENDERER_URL = 'https://kroki.io'
|
||||||
|
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS ='true'
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/index.html"
|
||||||
|
to = "/edit"
|
||||||
|
status = 301
|
||||||
|
force = true
|
||||||
|
|||||||
+108
-69
@@ -5,96 +5,135 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"dev:force": "MERMAID_LOCAL=true yarn dev --force",
|
"dev:force": "MERMAID_LOCAL=true pnpm dev --force",
|
||||||
"dev:test": "yarn dev",
|
"dev:test": "pnpm dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "prettier --check --cache . && eslint --ignore-path .gitignore .",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"lint:fix": "prettier --write --cache . && eslint --fix --ignore-path .gitignore .",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --check --cache . && eslint .",
|
||||||
|
"lint:fix": "prettier --write --cache . && eslint --fix .",
|
||||||
"format": "prettier --write --cache .",
|
"format": "prettier --write --cache .",
|
||||||
"pre-commit": "lint-staged",
|
"pre-commit": "lint-staged",
|
||||||
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
|
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:unit:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:unit:coverage": "vitest run --coverage",
|
||||||
"test:browser": "cypress run",
|
"test": "pnpm test:unit && pnpm test:e2e",
|
||||||
"test": "test:unit && test:browser",
|
"test:e2e": "playwright test",
|
||||||
"cy": "cypress open"
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:debug": "playwright test --debug"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/snapshot": "2.1.7",
|
"@eslint/compat": "^2.1.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@sveltejs/adapter-static": "3.0.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/kit": "2.5.16",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
"@iconify-json/hugeicons": "^1.2.29",
|
||||||
"@testing-library/svelte": "4.2.3",
|
"@iconify-json/logos": "^1.2.11",
|
||||||
|
"@iconify-json/material-symbols": "^1.2.79",
|
||||||
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.63.1",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"@types/hammerjs": "^2.0.46",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/pako": "2.0.3",
|
"@types/node": "^24.13.2",
|
||||||
"@types/uuid": "9.0.8",
|
"@types/pako": "2.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@typescript-eslint/parser": "6.21.0",
|
"@vitest/ui": "^4.1.9",
|
||||||
"@vitest/ui": "^1.1.3",
|
"autoprefixer": "^10.5.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"bits-ui": "^2.18.1",
|
||||||
"c8": "7.14.0",
|
"c8": "11.0.0",
|
||||||
"chai": "^4.3.7",
|
"chai": "^6.2.2",
|
||||||
"cssnano": "^6.0.0",
|
"clsx": "^2.1.1",
|
||||||
"cypress": "12.17.4",
|
"cssnano": "^8.0.2",
|
||||||
"cypress-localstorage-commands": "2.2.6",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "8.57.0",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-cypress": "2.15.2",
|
|
||||||
"eslint-plugin-es": "^4.1.0",
|
"eslint-plugin-es": "^4.1.0",
|
||||||
"eslint-plugin-no-only-tests": "^3.1.0",
|
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||||
"eslint-plugin-postcss-modules": "^2.0.0",
|
"eslint-plugin-sort-keys": "^2.3.5",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^3.19.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.13.1",
|
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||||
"eslint-plugin-unicorn": "^50.0.1",
|
"eslint-plugin-unicorn": "^65.0.1",
|
||||||
"eslint-plugin-vitest": "^0.5.0",
|
"globals": "^17.6.0",
|
||||||
"esserializer": "^1.3.11",
|
"husky": "^9.1.7",
|
||||||
"husky": "^8.0.3",
|
"jsdom": "^29.1.1",
|
||||||
"jsdom": "^21.1.2",
|
"lint-staged": "^17.0.7",
|
||||||
"lint-staged": "^15.2.0",
|
"lucide-svelte": "^1.0.1",
|
||||||
"node-html-parser": "^6.1.5",
|
"node-html-parser": "^7.1.0",
|
||||||
"postcss": "^8.4.33",
|
"paneforge": "1.0.2",
|
||||||
"postcss-load-config": "5.1.0",
|
"prettier": "^3.8.4",
|
||||||
"prettier": "^3.1.0",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
"svelte": "^5.56.3",
|
||||||
"svelte": "^4.2.8",
|
"svelte-check": "^4.6.0",
|
||||||
"svelte-preprocess": "^5.1.3",
|
"svelte-preprocess": "^6.0.5",
|
||||||
"tailwindcss": "^3.4.1",
|
"svelte-sonner": "^1.1.1",
|
||||||
"tslib": "^2.6.2",
|
"tailwind-merge": "^3.6.0",
|
||||||
"typescript": "^5.3.3",
|
"tailwind-variants": "^3.2.2",
|
||||||
"vite": "^5.0.11",
|
"tailwindcss": "^4.3.1",
|
||||||
"vitest": "^1.1.3",
|
"tslib": "^2.8.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.60.1",
|
||||||
|
"unplugin-icons": "^23.0.1",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"vitest-dom": "^0.1.1"
|
"vitest-dom": "^0.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mermaid-js/mermaid-zenuml": "^0.2.0",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"daisyui": "2.52.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"dayjs": "^1.11.7",
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
"js-base64": "3.7.7",
|
"@codemirror/language": "^6.12.3",
|
||||||
"lodash-es": "^4.17.21",
|
"@codemirror/state": "^6.6.0",
|
||||||
"mermaid": "10.9.1",
|
"@codemirror/view": "^6.43.1",
|
||||||
"monaco-editor": "0.49.0",
|
"@fontsource-variable/recursive": "^5.2.8",
|
||||||
|
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
|
||||||
|
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
|
||||||
|
"@mermaid-js/examples": "^1.2.0",
|
||||||
|
"@mermaid-js/layout-elk": "^0.2.1",
|
||||||
|
"@mermaid-js/layout-tidy-tree": "^0.2.2",
|
||||||
|
"@mermaid-js/mermaid-zenuml": "^0.2.3",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"hammerjs": "^2.0.8",
|
||||||
|
"js-base64": "3.7.8",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
|
"mermaid": "^11.15.0",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"monaco-editor": "0.55.1",
|
||||||
"pako": "2.1.0",
|
"pako": "2.1.0",
|
||||||
"plausible-tracker": "^0.3.8",
|
"plausible-tracker": "^0.3.9",
|
||||||
"random-word-slugs": "0.1.7",
|
"random-word-slugs": "0.1.7",
|
||||||
"svg-pan-zoom": "3.6.1",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"svg2roughjs": "^3.2.0",
|
"svg2roughjs": "^3.2.3",
|
||||||
"uuid": "9.0.1"
|
"uuid": "14.0.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,svelte,js,css,md,json}": [
|
"*.{ts,svelte,js,css,md,json}": [
|
||||||
"prettier --plugin-search-dir=. --write",
|
"prettier --plugin-search-dir=. --write",
|
||||||
"eslint --ignore-path .gitignore "
|
"eslint "
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"volta": {
|
|
||||||
"node": "18.20.3",
|
|
||||||
"yarn": "1.22.22"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.7"
|
"node": ">=24.16.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"deasync",
|
||||||
|
"esbuild",
|
||||||
|
"svelte-preprocess"
|
||||||
|
],
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"vue-demi"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
fullyParallel: true,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
reporter: process.env.CI ? [['github'], ['list']] : 'list',
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
testDir: './tests',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
browserName: 'chromium',
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
viewport: { width: 1920, height: 1080 }
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `pnpm ${process.env.CI ? 'preview' : 'dev'}`,
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
|
workers: process.env.CI ? 3 : undefined
|
||||||
|
});
|
||||||
Generated
+7273
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
|||||||
const tailwindcss = require('tailwindcss');
|
|
||||||
const autoprefixer = require('autoprefixer');
|
|
||||||
const cssnano = require('cssnano');
|
|
||||||
|
|
||||||
const mode = process.env.NODE_ENV;
|
|
||||||
const dev = mode === 'development';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
// Some plugins, like postcss-nested, need to run before Tailwind
|
|
||||||
|
|
||||||
tailwindcss,
|
|
||||||
|
|
||||||
// But others, like autoprefixer, need to run after
|
|
||||||
|
|
||||||
autoprefixer,
|
|
||||||
|
|
||||||
!dev &&
|
|
||||||
cssnano({
|
|
||||||
preset: 'default'
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
+2
-1
@@ -24,5 +24,6 @@
|
|||||||
"major": {
|
"major": {
|
||||||
"dependencyDashboardApproval": true
|
"dependencyDashboardApproval": true
|
||||||
},
|
},
|
||||||
"dependencyDashboardAutoclose": true
|
"dependencyDashboardAutoclose": true,
|
||||||
|
"rangeStrategy": "bump"
|
||||||
}
|
}
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
@import '@fontsource-variable/recursive/crsv.css';
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%);
|
||||||
|
--foreground: hsl(222.2 84% 4.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(222.2 84% 4.9%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||||
|
--primary: hsl(240 10% 91%);
|
||||||
|
--primary-foreground: hsl(255 20% 15%);
|
||||||
|
--secondary: hsl(210 40% 96.1%);
|
||||||
|
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
--muted: hsl(228 24% 96%);
|
||||||
|
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||||
|
--accent: hsl(340 100% 44%);
|
||||||
|
--accent-foreground: hsl(210 40% 98%);
|
||||||
|
--destructive: hsl(0 72.22% 50.59%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--border: hsl(214.3 31.8% 91.4%);
|
||||||
|
--border-dark: hsl(214.3 31.8% 81.4%);
|
||||||
|
--input: hsl(214.3 31.8% 91.4%);
|
||||||
|
--ring: hsl(222.2 84% 4.9%);
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: hsl(222.2 84% 4.9%);
|
||||||
|
--foreground: hsl(210 40% 98%);
|
||||||
|
--card: hsl(222.2 84% 4.9%);
|
||||||
|
--card-foreground: hsl(210 40% 98%);
|
||||||
|
--popover: hsl(222.2 84% 4.9%);
|
||||||
|
--popover-foreground: hsl(210 40% 98%);
|
||||||
|
--primary: hsl(210 40% 30%);
|
||||||
|
--primary-foreground: hsl(222.2 47.4% 90%);
|
||||||
|
--secondary: hsl(217.2 32.6% 17.5%);
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
--muted: hsl(217.2 32.6% 17.5%);
|
||||||
|
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||||
|
--accent: hsl(340 100% 44%);
|
||||||
|
--accent-foreground: hsl(210 40% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--border: hsl(217.2 32.6% 17.5%);
|
||||||
|
--border-dark: hsl(217.2 32.6% 27.5%);
|
||||||
|
--input: hsl(217.2 32.6% 17.5%);
|
||||||
|
--ring: hsl(212.7 26.8% 83.9%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
/* Radius (for rounded-*) */
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-border-dark: var(--border-dark);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-radius: var(--radius);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply h-screen w-screen overflow-hidden bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Recursive Variable', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d {
|
||||||
|
@apply border border-red-500;
|
||||||
|
}
|
||||||
+5
-7
@@ -4,20 +4,18 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
||||||
<meta
|
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
|
||||||
name="og:image"
|
|
||||||
content="https://github.com/mermaid-js/mermaid/raw/develop/img/header.png" />
|
|
||||||
<link rel="canonical" href="https://mermaid.live" />
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
|
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
|
||||||
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
|
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
|
||||||
<meta name="theme-color" content="#6366F1" />
|
<meta name="theme-color" content="#ff3670" />
|
||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body style="overflow: hidden">
|
<body>
|
||||||
<div id="svelte">%sveltekit.body%</div>
|
<div id="svelte">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
@import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
.input {
|
|
||||||
@apply flex-1 rounded border-2 border-solid border-primary;
|
|
||||||
}
|
|
||||||
.action-btn {
|
|
||||||
@apply btn btn-primary;
|
|
||||||
}
|
|
||||||
#graph-div sub,
|
|
||||||
#graph-div sup {
|
|
||||||
position: initial;
|
|
||||||
vertical-align: revert;
|
|
||||||
}
|
|
||||||
Vendored
+4
@@ -4,7 +4,11 @@ interface ImportMetaEnv {
|
|||||||
readonly MERMAID_RENDERER_URL?: string;
|
readonly MERMAID_RENDERER_URL?: string;
|
||||||
readonly MERMAID_KROKI_RENDERER_URL?: string;
|
readonly MERMAID_KROKI_RENDERER_URL?: string;
|
||||||
readonly MERMAID_ANALYTICS_URL?: string;
|
readonly MERMAID_ANALYTICS_URL?: string;
|
||||||
|
readonly MERMAID_DOCS_URL?: string;
|
||||||
readonly MERMAID_DOMAIN?: string;
|
readonly MERMAID_DOMAIN?: string;
|
||||||
|
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
|
||||||
|
readonly MERMAID_PRIVACY_POLICY_URL?: string;
|
||||||
|
readonly MERMAID_HIDE_PRIVACY_POLICY?: string;
|
||||||
// more env variables...
|
// more env variables...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
|
||||||
const response = await resolve(event, {});
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import CloseIcon from '~icons/material-symbols/close-rounded';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean;
|
||||||
|
input: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onHeightChange?: (height: number) => void;
|
||||||
|
onTryFree: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { show, input = $bindable(), onClose, onHeightChange, onTryFree }: Props = $props();
|
||||||
|
|
||||||
|
let textarea = $state<HTMLTextAreaElement>();
|
||||||
|
let container = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!container || !onHeightChange) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target instanceof HTMLElement) {
|
||||||
|
onHeightChange(entry.target.offsetHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resizeTextarea() {
|
||||||
|
if (!textarea) return;
|
||||||
|
const computed = globalThis.getComputedStyle(textarea);
|
||||||
|
const lineHeight =
|
||||||
|
Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.5 || 0;
|
||||||
|
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
|
||||||
|
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
|
||||||
|
const minHeight = lineHeight + paddingTop + paddingBottom;
|
||||||
|
const maxLines = 8;
|
||||||
|
const maxHeight = lineHeight * maxLines + paddingTop + paddingBottom;
|
||||||
|
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight));
|
||||||
|
textarea.style.height = `${nextHeight}px`;
|
||||||
|
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (input !== undefined) {
|
||||||
|
resizeTextarea();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (show && e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (show && container && e.target instanceof Node && !container.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} onmousedown={handleOutsideClick} />
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class={cn(
|
||||||
|
'button-container-for-animation relative z-50 mr-6 flex w-auto flex-col gap-2 rounded-xl border-2 border-border bg-background p-2 shadow-xl dark:border-border-dark dark:bg-secondary',
|
||||||
|
!input.trim() && 'rainbow-border'
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1">
|
||||||
|
<div class="relative flex min-h-2 items-start gap-1 px-1">
|
||||||
|
<textarea
|
||||||
|
bind:this={textarea}
|
||||||
|
bind:value={input}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.trim()) {
|
||||||
|
onTryFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Describe what to add or change"
|
||||||
|
rows="1"
|
||||||
|
class="focus font-recursive min-h-0 flex-1 resize-none border-none bg-transparent px-1 text-sm font-normal text-foreground placeholder:text-muted-foreground focus:ring-0 focus:outline-none disabled:opacity-50 dark:text-foreground dark:placeholder:text-muted-foreground"
|
||||||
|
style="height: 20px; overflow-y: hidden;"></textarea>
|
||||||
|
<button onclick={onClose} class="text-muted-foreground hover:text-foreground">
|
||||||
|
<CloseIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-recursive text-xs font-normal text-foreground dark:text-foreground"
|
||||||
|
>Sign Up at Mermaid.ai to try AI</span>
|
||||||
|
<Button
|
||||||
|
class="font-recursive h-6 w-16 gap-1.5 rounded-sm bg-accent p-1 text-xs font-medium text-white no-underline hover:bg-accent/90 hover:text-white hover:no-underline active:bg-accent/80 dark:bg-accent dark:text-white! dark:hover:bg-accent/90 dark:active:bg-accent/80"
|
||||||
|
onclick={onTryFree}>
|
||||||
|
Try free
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@property --gradient-angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-angle-shift {
|
||||||
|
0% {
|
||||||
|
--gradient-angle: 0deg;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
--gradient-angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container-for-animation {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container-for-animation::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 2px;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--gradient-angle, 0deg),
|
||||||
|
color-mix(in srgb, var(--color-accent) 0%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 0%, transparent) 12%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 12%, transparent) 18%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 42%, transparent) 24%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 77%, transparent) 32%,
|
||||||
|
rgba(93, 85, 212, 0.923) 41%,
|
||||||
|
rgba(93, 85, 212, 0.5) 52%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 58%, transparent) 72%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 56%, transparent) 82%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 36%, transparent) 87%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 19%, transparent) 92%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 10%, transparent) 96%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 3%, transparent) 98%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 0%, transparent) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container-for-animation.rainbow-border::before {
|
||||||
|
opacity: 1;
|
||||||
|
animation: gradient-angle-shift 2s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+198
-144
@@ -1,33 +1,93 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Card from '$/components/Card/Card.svelte';
|
||||||
|
import CopyButton from '$/components/CopyButton.svelte';
|
||||||
|
import CopyInput from '$/components/CopyInput.svelte';
|
||||||
|
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { Input } from '$/components/ui/input';
|
||||||
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import * as ToggleGroup from '$/components/ui/toggle-group';
|
||||||
|
import { TID } from '$/constants';
|
||||||
|
import { getDomain } from '$/util/util';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import Card from '$lib/components/Card/Card.svelte';
|
|
||||||
import { waitForRender } from '$lib/util/autoSync';
|
import { waitForRender } from '$lib/util/autoSync';
|
||||||
import { env } from '$lib/util/env';
|
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
|
||||||
import { pakoSerde } from '$lib/util/serde';
|
|
||||||
import { stateStore } from '$lib/util/state';
|
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { logEvent } from '$lib/util/stats';
|
||||||
|
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { toBase64 } from 'js-base64';
|
import { toBase64 } from 'js-base64';
|
||||||
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
|
import DownloadIcon from '~icons/material-symbols/download';
|
||||||
|
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||||
|
import WidthIcon from '~icons/material-symbols/width-rounded';
|
||||||
|
|
||||||
const FONT_AWESOME_URL = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}/css/all.min.css`;
|
const FONT_AWESOME_URL = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}/css/all.min.css`;
|
||||||
|
|
||||||
const { krokiRendererUrl, rendererUrl } = env;
|
|
||||||
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
|
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
|
||||||
|
|
||||||
const getFileName = (extension: string) =>
|
const getFileName = (extension: string) =>
|
||||||
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`;
|
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix text clipping in exported SVG for hand-drawn (rough) mode.
|
||||||
|
* svg2roughjs copies foreignObject elements but their height is often insufficient,
|
||||||
|
* causing text bottom edges to be cut off regardless of language.
|
||||||
|
*/
|
||||||
|
const fixForeignObjectClipping = (svg: HTMLElement) => {
|
||||||
|
const foreignObjects = svg.querySelectorAll('foreignObject');
|
||||||
|
foreignObjects.forEach((foreignObj) => {
|
||||||
|
const currentHeight = parseFloat(foreignObj.getAttribute('height') || '0');
|
||||||
|
if (currentHeight <= 0) return;
|
||||||
|
|
||||||
|
const currentY = parseFloat(foreignObj.getAttribute('y') || '0');
|
||||||
|
const newHeight = currentHeight * 1.5;
|
||||||
|
const heightDiff = newHeight - currentHeight;
|
||||||
|
|
||||||
|
foreignObj.setAttribute('height', newHeight.toString());
|
||||||
|
foreignObj.setAttribute('y', (currentY - heightDiff / 2).toString());
|
||||||
|
|
||||||
|
// Ensure inner HTML elements are vertically centered within the expanded area
|
||||||
|
const htmlElements = foreignObj.querySelectorAll('div, span, p');
|
||||||
|
htmlElements.forEach((htmlEl) => {
|
||||||
|
const el = htmlEl as HTMLElement;
|
||||||
|
el.style.display = 'flex';
|
||||||
|
el.style.alignItems = 'center';
|
||||||
|
el.style.justifyContent = 'center';
|
||||||
|
el.style.height = '100%';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSvgElement = () => {
|
||||||
|
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
|
||||||
|
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||||
|
return svgElement;
|
||||||
|
};
|
||||||
|
|
||||||
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
|
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
|
||||||
if (svg) {
|
if (svg) {
|
||||||
// Prevents the SVG size of the interface from being changed
|
// Prevents the SVG size of the interface from being changed
|
||||||
svg = svg.cloneNode(true) as HTMLElement;
|
svg = svg.cloneNode(true) as HTMLElement;
|
||||||
}
|
}
|
||||||
height && svg?.setAttribute('height', `${height}px`);
|
if (height) {
|
||||||
width && svg?.setAttribute('width', `${width}px`); // Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
|
svg?.setAttribute('height', `${height}px`);
|
||||||
|
}
|
||||||
|
if (width) {
|
||||||
|
svg?.setAttribute('width', `${width}px`);
|
||||||
|
}
|
||||||
|
// Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
|
||||||
|
|
||||||
if (!svg) {
|
if (!svg) {
|
||||||
svg = getSvgElement();
|
svg = getSvgElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (validatedState.current.rough) {
|
||||||
|
fixForeignObjectClipping(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.style.backgroundColor = window
|
||||||
|
.getComputedStyle(document.body)
|
||||||
|
.getPropertyValue('--background');
|
||||||
|
|
||||||
const svgString = svg.outerHTML
|
const svgString = svg.outerHTML
|
||||||
.replaceAll('<br>', '<br/>')
|
.replaceAll('<br>', '<br/>')
|
||||||
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
|
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
|
||||||
@@ -37,50 +97,6 @@
|
|||||||
${svgString}`);
|
${svgString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportImage = async (event: Event, exporter: Exporter) => {
|
|
||||||
await waitForRender();
|
|
||||||
if (document.querySelector('.outOfSync')) {
|
|
||||||
throw new Error('Diagram is out of sync');
|
|
||||||
}
|
|
||||||
const canvas: HTMLCanvasElement = document.createElement('canvas');
|
|
||||||
const svg = document.querySelector<HTMLElement>('#container svg');
|
|
||||||
if (!svg) {
|
|
||||||
throw new Error('svg not found');
|
|
||||||
}
|
|
||||||
const box: DOMRect = svg.getBoundingClientRect();
|
|
||||||
canvas.width = box.width;
|
|
||||||
canvas.height = box.height;
|
|
||||||
if (imagemodeselected === 'width') {
|
|
||||||
const ratio = box.height / box.width;
|
|
||||||
canvas.width = userimagesize;
|
|
||||||
canvas.height = userimagesize * ratio;
|
|
||||||
} else if (imagemodeselected === 'height') {
|
|
||||||
const ratio = box.width / box.height;
|
|
||||||
canvas.width = userimagesize * ratio;
|
|
||||||
canvas.height = userimagesize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('context not found');
|
|
||||||
}
|
|
||||||
context.fillStyle = `hsl(${window.getComputedStyle(document.body).getPropertyValue('--b1')})`;
|
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
image.addEventListener('load', exporter(context, image));
|
|
||||||
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
|
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSvgElement = () => {
|
|
||||||
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
|
|
||||||
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
|
||||||
return svgElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const simulateDownload = (download: string, href: string): void => {
|
const simulateDownload = (download: string, href: string): void => {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.download = download;
|
a.download = download;
|
||||||
@@ -88,6 +104,65 @@ ${svgString}`);
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportImage = async (event: Event, exporter: Exporter) => {
|
||||||
|
updateCodeStore({ panZoom: false });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
await waitForRender();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const svg = document.querySelector<HTMLElement>('#container svg');
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error('svg not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = svg.getBoundingClientRect();
|
||||||
|
|
||||||
|
// In rough mode, SVG has width/height="100%" so getBoundingClientRect returns
|
||||||
|
// the container size, not the actual diagram size. Use viewBox dimensions instead.
|
||||||
|
const svgEl = svg as unknown as SVGSVGElement;
|
||||||
|
const viewBox = svgEl.viewBox?.baseVal;
|
||||||
|
const contentWidth = viewBox && viewBox.width > 0 ? viewBox.width : box.width;
|
||||||
|
const contentHeight = viewBox && viewBox.height > 0 ? viewBox.height : box.height;
|
||||||
|
|
||||||
|
if (imageSizeMode === 'width') {
|
||||||
|
const ratio = contentHeight / contentWidth;
|
||||||
|
canvas.width = imageSize;
|
||||||
|
canvas.height = imageSize * ratio;
|
||||||
|
} else if (imageSizeMode === 'height') {
|
||||||
|
const ratio = contentWidth / contentHeight;
|
||||||
|
canvas.width = imageSize * ratio;
|
||||||
|
canvas.height = imageSize;
|
||||||
|
} else {
|
||||||
|
const multiplier = 2;
|
||||||
|
canvas.width = contentWidth * multiplier;
|
||||||
|
canvas.height = contentHeight * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('context not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = window.getComputedStyle(document.body).getPropertyValue('--background');
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.addEventListener('load', () => {
|
||||||
|
exporter(context, image)();
|
||||||
|
updateCodeStore({ panZoom: true });
|
||||||
|
});
|
||||||
|
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
|
||||||
|
// Fallback to set panZoom to true after 2 seconds
|
||||||
|
// This is a workaround for the case when the image is not loaded
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!inputState.panZoom) {
|
||||||
|
updateCodeStore({ panZoom: true });
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
const downloadImage: Exporter = (context, image) => {
|
const downloadImage: Exporter = (context, image) => {
|
||||||
return () => {
|
return () => {
|
||||||
const { canvas } = context;
|
const { canvas } = context;
|
||||||
@@ -100,7 +175,7 @@ ${svgString}`);
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isClipboardAvailable = (): boolean => {
|
const isClipboardAvailable = (): boolean => {
|
||||||
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem') as boolean;
|
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem');
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipboardCopy: Exporter = (context, image) => {
|
const clipboardCopy: Exporter = (context, image) => {
|
||||||
@@ -124,7 +199,10 @@ ${svgString}`);
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyClipboard = async (event: Event) => {
|
const onCopyClipboard = async (event?: Event) => {
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await exportImage(event, clipboardCopy);
|
await exportImage(event, clipboardCopy);
|
||||||
logEvent('copyClipboard');
|
logEvent('copyClipboard');
|
||||||
};
|
};
|
||||||
@@ -143,120 +221,96 @@ ${svgString}`);
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyMarkdown = () => {
|
let gistURL = $state('');
|
||||||
document.querySelector<HTMLInputElement>('#markdown')?.select();
|
$effect(() => {
|
||||||
document.execCommand('Copy');
|
const { loader } = validatedState.current;
|
||||||
logEvent('copyMarkdown');
|
|
||||||
};
|
|
||||||
|
|
||||||
let gistURL = '';
|
|
||||||
stateStore.subscribe(({ loader }) => {
|
|
||||||
if (loader?.type === 'gist') {
|
if (loader?.type === 'gist') {
|
||||||
// @ts-expect-error Gist will have url
|
|
||||||
gistURL = loader.config.url;
|
gistURL = loader.config.url;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadGist = () => {
|
const loadGist = () => {
|
||||||
if (!gistURL) {
|
if (!gistURL) {
|
||||||
alert('Please enter a Gist URL first');
|
return alert('Please enter a Gist URL first');
|
||||||
}
|
}
|
||||||
window.location.href = `${window.location.pathname}?gist=${gistURL}`;
|
window.location.href = `${window.location.pathname}?gist=${gistURL}`;
|
||||||
logEvent('loadGist');
|
logEvent('loadGist');
|
||||||
};
|
};
|
||||||
|
|
||||||
let iUrl: string;
|
let imageSizeMode: 'auto' | 'width' | 'height' = $state('auto');
|
||||||
let svgUrl: string;
|
|
||||||
let krokiUrl: string;
|
|
||||||
let mdCode: string;
|
|
||||||
let imagemodeselected = 'auto';
|
|
||||||
let userimagesize = 1080;
|
|
||||||
|
|
||||||
let isNetlify = false;
|
$effect(() => {
|
||||||
if (browser && ['mermaid.live', 'netlify'].some((path) => window.location.host.includes(path))) {
|
if (!imageSizeMode) {
|
||||||
isNetlify = true;
|
imageSizeMode = 'auto';
|
||||||
}
|
}
|
||||||
stateStore.subscribe(({ code, serialized }) => {
|
|
||||||
iUrl = `${rendererUrl}/img/${serialized}?type=png`;
|
|
||||||
svgUrl = `${rendererUrl}/svg/${serialized}`;
|
|
||||||
krokiUrl = `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}`;
|
|
||||||
mdCode = `[](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let imageSize = $state(1080);
|
||||||
|
|
||||||
|
const isNetlify = browser && window.location.host.includes('netlify');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Actions" isOpen={false}>
|
{#snippet dualActionButton(text: string, download: (event: Event) => unknown, url?: string)}
|
||||||
<div class="m-2 flex flex-wrap gap-2">
|
<div class="flex flex-grow gap-0.5">
|
||||||
{#if isClipboardAvailable()}
|
<Button
|
||||||
<button class="action-btn w-full" on:click={onCopyClipboard}
|
class={['flex-grow', url && 'rounded-r-none']}
|
||||||
><i class="far fa-copy mr-2" /> Copy Image to clipboard
|
onclick={download}
|
||||||
</button>
|
data-testid="download-{text}">
|
||||||
{/if}
|
<DownloadIcon />
|
||||||
<button id="downloadPNG" class="action-btn flex-grow" on:click={onDownloadPNG}>
|
{text}
|
||||||
<i class="fas fa-download mr-2" /> PNG
|
</Button>
|
||||||
</button>
|
<ExternalLinkWrapper domain={getDomain(url)} isVisible={!!url}>
|
||||||
<button id="downloadSVG" class="action-btn flex-grow" on:click={onDownloadSVG}>
|
<Button class="rounded-l-none" href={url} target="_blank" rel="noreferrer noopener">
|
||||||
<i class="fas fa-download mr-2" /> SVG
|
<ExternalLinkIcon />
|
||||||
</button>
|
</Button>
|
||||||
<a target="_blank" rel="noreferrer" class="flex-grow" href={iUrl}>
|
</ExternalLinkWrapper>
|
||||||
<button class="action-btn w-full">
|
</div>
|
||||||
<i class="fas fa-external-link-alt mr-2" /> PNG
|
{/snippet}
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<a target="_blank" rel="noreferrer" class="flex-grow" href={svgUrl}>
|
|
||||||
<button class="action-btn w-full">
|
|
||||||
<i class="fas fa-external-link-alt mr-2" /> SVG
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<a target="_blank" rel="noreferrer" class="flex-grow" href={krokiUrl}>
|
|
||||||
<button class="action-btn w-full">
|
|
||||||
<i class="fas fa-external-link-alt mr-2" /> Kroki
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<Card title="Actions" isStackable icon={{ component: DownloadIcon, class: 'rotate-180' }}>
|
||||||
|
<div class="flex min-w-fit flex-col gap-2 p-2">
|
||||||
|
<div class="flex w-full items-center gap-2 py-2 whitespace-nowrap">
|
||||||
PNG size
|
PNG size
|
||||||
<label for="autosize">
|
<ToggleGroup.Root type="single" variant="outline" bind:value={imageSizeMode}>
|
||||||
<input type="radio" value="auto" id="autosize" bind:group={imagemodeselected} /> Auto
|
<ToggleGroup.Item value="auto">Auto</ToggleGroup.Item>
|
||||||
</label>
|
<ToggleGroup.Item value="width">Width</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item value="height">Height</ToggleGroup.Item>
|
||||||
<label for="width">
|
</ToggleGroup.Root>
|
||||||
<input type="radio" value="width" id="width" bind:group={imagemodeselected} /> Width
|
{#if imageSizeMode !== 'auto'}
|
||||||
</label>
|
<WidthIcon
|
||||||
|
class={['size-6 shrink-0 transition-all', imageSizeMode === 'width' && 'rotate-90']} />
|
||||||
<label for="height">
|
{/if}
|
||||||
<input type="radio" value="height" id="height" bind:group={imagemodeselected} /> Height
|
<Input
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if imagemodeselected !== 'auto'}
|
|
||||||
<input
|
|
||||||
id="height"
|
|
||||||
class="input"
|
|
||||||
type="number"
|
type="number"
|
||||||
min="3"
|
min="3"
|
||||||
max="10000"
|
max="10000"
|
||||||
bind:value={userimagesize} />
|
disabled={imageSizeMode === 'auto'}
|
||||||
|
bind:value={imageSize} />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
|
||||||
|
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
|
||||||
|
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
|
||||||
|
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
|
||||||
|
<Button class="action-btn flex w-full items-center gap-2">
|
||||||
|
<ExternalLinkIcon /> Kroki
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</ExternalLinkWrapper>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{#if isClipboardAvailable()}
|
||||||
|
<CopyButton onclick={onCopyClipboard} label="Copy Image" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<ExternalLinkWrapper
|
||||||
|
labelPrefix="Thumbnail generated by"
|
||||||
|
domain={getDomain(urls.current.png)}
|
||||||
|
isVisible={!!urls.current.mdCode}>
|
||||||
|
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
|
||||||
|
</ExternalLinkWrapper>
|
||||||
<div class="flex w-full items-center gap-2">
|
<div class="flex w-full items-center gap-2">
|
||||||
<input class="input" id="markdown" type="text" value={mdCode} on:click={onCopyMarkdown} />
|
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
|
||||||
<label for="markdown">
|
<Button onclick={loadGist}>Load Gist</Button>
|
||||||
<button class="btn btn-primary btn-md flex-auto" on:click={onCopyMarkdown}>
|
|
||||||
Copy Markdown
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full items-center gap-2">
|
|
||||||
<input
|
|
||||||
class="input"
|
|
||||||
id="gist"
|
|
||||||
type="text"
|
|
||||||
bind:value={gistURL}
|
|
||||||
placeholder="Enter Gist URL" />
|
|
||||||
<label for="gist">
|
|
||||||
<button class="btn btn-primary btn-md flex-auto" on:click={loadGist}> Load Gist </button>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{#if isNetlify}
|
{#if isNetlify}
|
||||||
<div class="flex w-full items-center justify-center">
|
<div class="flex w-full items-center justify-center">
|
||||||
|
|||||||
@@ -1,33 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tab } from '$lib/types';
|
import type { Tab } from '$/types';
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import CollapseAllIcon from '~icons/material-symbols/collapse-all-rounded';
|
||||||
import Tabs from './Tabs.svelte';
|
import Tabs from './Tabs.svelte';
|
||||||
export let isCloseable = true;
|
|
||||||
export let isOpen = true;
|
interface Props {
|
||||||
export let tabs: Tab[] = [];
|
isClosable?: boolean;
|
||||||
export let activeTabID = '';
|
isOpen?: boolean;
|
||||||
export let title: string;
|
isStackable?: boolean;
|
||||||
$: isOpen = isCloseable ? isOpen : true;
|
tabs?: Tab[];
|
||||||
$: isTabsShown = isOpen && tabs.length > 0;
|
activeTabID?: string;
|
||||||
|
title?: string;
|
||||||
|
icon?: {
|
||||||
|
component: Component;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
onselect?: (tab: Tab) => void;
|
||||||
|
actions?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isClosable = true,
|
||||||
|
isOpen = false,
|
||||||
|
isStackable = false,
|
||||||
|
tabs = [],
|
||||||
|
activeTabID = '',
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onselect,
|
||||||
|
actions,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const toggleCardOpen = () => {
|
||||||
|
if (isClosable) {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let isTabsShown = $derived(isOpen && tabs.length > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card m-2 flex flex-grow flex-col overflow-hidden rounded shadow-2xl">
|
<div
|
||||||
|
class={[
|
||||||
|
'card flex h-fit flex-col overflow-hidden rounded-2xl border-2 border-muted',
|
||||||
|
isOpen && 'isOpen flex-grow',
|
||||||
|
isStackable ? 'flex-1 group-has-[.isOpen]:w-full group-has-[.isOpen]:flex-none' : 'w-full'
|
||||||
|
]}>
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="bg-primary p-2 {isTabsShown ? 'pb-0' : ''} flex-none cursor-pointer"
|
class={[
|
||||||
on:click={() => (isOpen = !isOpen)}
|
'flex h-11 flex-none cursor-pointer items-center justify-between bg-muted p-2 whitespace-nowrap',
|
||||||
on:keypress={() => (isOpen = !isOpen)}>
|
isTabsShown && 'pb-1'
|
||||||
<div class="flex justify-between">
|
]}
|
||||||
<Tabs on:select {tabs} bind:isOpen {title} {isCloseable} {activeTabID} />
|
onclick={toggleCardOpen}
|
||||||
<div class="flex items-center gap-x-4 {isTabsShown ? '-mt-2' : ''}">
|
onkeypress={toggleCardOpen}>
|
||||||
<slot name="actions" />
|
{#if icon || title}
|
||||||
</div>
|
<span role="menubar" tabindex="0" class="flex w-fit items-center gap-3">
|
||||||
</div>
|
{#if icon}
|
||||||
|
<icon.component class={icon.class} />
|
||||||
|
{/if}
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if isOpen && tabs && tabs.length > 0}
|
||||||
|
<Tabs {onselect} {tabs} {activeTabID} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{@render actions?.()}
|
||||||
|
|
||||||
|
{#if isOpen && isClosable}
|
||||||
|
<CollapseAllIcon />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="card-body flex-grow overflow-auto p-0 text-base-content" transition:slide>
|
<div class="flex-grow overflow-x-auto" transition:slide={{ easing: quintOut }}>
|
||||||
<slot />
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tab, TabEvents } from '$lib/types';
|
import { Button } from '$/components/ui/button';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import type { Tab } from '$lib/types';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
export let isCloseable = true;
|
|
||||||
export let tabs: Tab[];
|
|
||||||
export let title: string;
|
|
||||||
export let isOpen = false;
|
|
||||||
export let activeTabID: string;
|
|
||||||
|
|
||||||
if (!activeTabID && tabs.length > 0) {
|
let {
|
||||||
activeTabID = tabs[0].id;
|
tabs,
|
||||||
}
|
activeTabID,
|
||||||
const dispatch = createEventDispatcher<TabEvents>();
|
onselect
|
||||||
|
}: {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabID: string;
|
||||||
|
onselect?: (tab: Tab) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
|
||||||
|
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
|
||||||
|
|
||||||
const toggleTabs = (tab: Tab) => {
|
const toggleTabs = (tab: Tab) => {
|
||||||
activeTabID = tab.id;
|
return (event: Event) => {
|
||||||
dispatch('select', tab);
|
event.stopPropagation();
|
||||||
|
onselect?.(tab);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex cursor-default">
|
<div class="flex w-fit cursor-default items-center gap-2">
|
||||||
<span
|
<ul class="flex gap-2 align-middle" transition:fade>
|
||||||
role="menubar"
|
{#each tabs as tab, index (tab.id)}
|
||||||
tabindex="0"
|
<Button
|
||||||
class="mr-2 font-semibold"
|
|
||||||
on:click|stopPropagation={() => (isOpen = !isOpen)}
|
|
||||||
on:keypress|stopPropagation={() => (isOpen = !isOpen)}>
|
|
||||||
{#if isCloseable}
|
|
||||||
<i class="fas fa-chevron-right icon" class:isOpen />
|
|
||||||
{/if}
|
|
||||||
{title}</span>
|
|
||||||
{#if isOpen && tabs}
|
|
||||||
<ul class="tabs" transition:fade>
|
|
||||||
{#each tabs as tab}
|
|
||||||
<div
|
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex="0"
|
variant="ghost"
|
||||||
class="tab tab-lifted {activeTabID === tab.id ? 'tab-active' : 'text-primary-content'}"
|
class={[
|
||||||
on:click|stopPropagation={() => toggleTabs(tab)}
|
'px-2',
|
||||||
on:keypress|stopPropagation={() => toggleTabs(tab)}>
|
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
|
||||||
<i class="mr-1 {tab.icon}" />
|
]}
|
||||||
|
onclick={toggleTabs(tab)}
|
||||||
|
onkeypress={toggleTabs(tab)}>
|
||||||
|
<tab.icon />
|
||||||
{tab.title}
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if index < tabs.length - 1}
|
||||||
|
<div class="my-2">
|
||||||
|
<Separator orientation="vertical" class="w-0.5 bg-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.icon {
|
|
||||||
transition-duration: 0.5s;
|
|
||||||
}
|
|
||||||
.isOpen {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { cleanup, render } from '@testing-library/svelte';
|
|
||||||
import { describe, expect, it, afterEach } from 'vitest';
|
|
||||||
import Card from './Card.svelte';
|
|
||||||
|
|
||||||
describe('card.svelte', () => {
|
|
||||||
// TODO: @testing-library/svelte claims to add this automatically but it doesn't work without explicit afterEach
|
|
||||||
afterEach(() => cleanup());
|
|
||||||
|
|
||||||
it('mounts', () => {
|
|
||||||
const { container } = render(Card, {
|
|
||||||
title: 'TabTest',
|
|
||||||
tabs: [
|
|
||||||
{ id: 't1', title: 'title1', icon: 'fab fa-git-alt' },
|
|
||||||
{ id: 't2', title: 'title2', icon: 'far fa-bookmark' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
expect(container).toHaveTextContent('TabTest');
|
|
||||||
expect(container).toHaveTextContent('title1');
|
|
||||||
expect(container).toHaveTextContent('title2');
|
|
||||||
expect(container).not.toHaveTextContent('title3');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { notify } from '$/util/notify';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
import CheckIcon from '~icons/material-symbols/check-rounded';
|
||||||
|
import CopyIcon from '~icons/material-symbols/content-copy-outline-rounded';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onclick,
|
||||||
|
label = 'Copy'
|
||||||
|
}: { onclick: (event?: Event) => Promise<unknown>; label?: string } = $props();
|
||||||
|
|
||||||
|
let showCheckIcon = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onclick={async (event) => {
|
||||||
|
try {
|
||||||
|
showCheckIcon = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showCheckIcon = false;
|
||||||
|
}, 1000);
|
||||||
|
await onclick(event);
|
||||||
|
} catch {
|
||||||
|
notify('Failed to copy');
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="grid">
|
||||||
|
{#key showCheckIcon}
|
||||||
|
<span transition:scale class="col-start-1 row-start-1">
|
||||||
|
{#if showCheckIcon}
|
||||||
|
<CheckIcon />
|
||||||
|
{:else}
|
||||||
|
<CopyIcon />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CopyButton from '$/components/CopyButton.svelte';
|
||||||
|
import { Input } from '$/components/ui/input';
|
||||||
|
import type { InputType } from '$/types';
|
||||||
|
import { copyToClipboard } from '$/util/util';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
label = 'Copy',
|
||||||
|
type = 'url',
|
||||||
|
testID
|
||||||
|
}: { value: string; label?: string; type?: InputType; testID?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex w-full items-center gap-2">
|
||||||
|
<Input
|
||||||
|
{type}
|
||||||
|
{value}
|
||||||
|
data-testid={testID}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.currentTarget.setSelectionRange(0, event.currentTarget.value.length);
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<CopyButton onclick={() => copyToClipboard(value)} {label} />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { EditorProps } from '$/types';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { urls, validatedState } from '$/util/state.svelte';
|
||||||
|
import { logMermaidChartClick } from '$/util/stats';
|
||||||
|
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
|
||||||
|
import { initEditor } from '$lib/util/monacoExtra';
|
||||||
|
import { errorDebug } from '$lib/util/util';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import * as monaco from 'monaco-editor';
|
||||||
|
import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import AIPromptPopup from './AIPromptPopup.svelte';
|
||||||
|
|
||||||
|
const { onUpdate }: EditorProps = $props();
|
||||||
|
|
||||||
|
let divElement: HTMLDivElement | undefined = $state();
|
||||||
|
let aiPromptPopupElement: HTMLDivElement | undefined = $state();
|
||||||
|
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
||||||
|
let editorOptions = {
|
||||||
|
minimap: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
glyphMargin: true,
|
||||||
|
lineNumbersMinChars: 4
|
||||||
|
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
|
||||||
|
let currentText = '';
|
||||||
|
let isUpdatingFromState = false;
|
||||||
|
let showPopup = $state(false);
|
||||||
|
let popupPosition = $state({ top: 0, lineNumber: 0 });
|
||||||
|
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
|
||||||
|
let input = $state('');
|
||||||
|
let lastMouseLine = 0;
|
||||||
|
const aiPromptManager = new AIPromptViewZoneManager();
|
||||||
|
|
||||||
|
const applyEditorTheme = (currentMode: typeof mode.current) => {
|
||||||
|
if (!editor) return;
|
||||||
|
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
|
||||||
|
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
applyEditorTheme(mode.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonModel = monaco.editor.createModel(
|
||||||
|
'',
|
||||||
|
'json',
|
||||||
|
monaco.Uri.parse('internal://config.json')
|
||||||
|
);
|
||||||
|
const mermaidModel = monaco.editor.createModel(
|
||||||
|
'',
|
||||||
|
'mermaid',
|
||||||
|
monaco.Uri.parse('internal://mermaid.mmd')
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAIPromptGutterGlyphIcon = () => {
|
||||||
|
decorationsCollection?.clear();
|
||||||
|
if (!editor || showPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMouseLine > 0 && model.id === mermaidModel.id) {
|
||||||
|
decorationsCollection?.set([
|
||||||
|
{
|
||||||
|
range: new monaco.Range(lastMouseLine, 1, lastMouseLine, 1),
|
||||||
|
options: {
|
||||||
|
glyphMarginClassName: 'suggestion-icon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePopup = () => {
|
||||||
|
showPopup = false;
|
||||||
|
input = '';
|
||||||
|
aiPromptManager.hide();
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAIPopup = (lineNumber: number) => {
|
||||||
|
if (!divElement || !aiPromptPopupElement) return;
|
||||||
|
popupPosition = {
|
||||||
|
top: 0,
|
||||||
|
lineNumber
|
||||||
|
};
|
||||||
|
showPopup = !showPopup;
|
||||||
|
if (showPopup) {
|
||||||
|
aiPromptManager.show(popupPosition.lineNumber, aiPromptPopupElement, 100);
|
||||||
|
editor?.setSelection(new monaco.Range(0, 0, 0, 0));
|
||||||
|
} else {
|
||||||
|
aiPromptManager.hide();
|
||||||
|
}
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker(_, label) {
|
||||||
|
if (label === 'json') {
|
||||||
|
return new monacoJsonWorker();
|
||||||
|
}
|
||||||
|
return new monacoEditorWorker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!divElement) {
|
||||||
|
throw new Error('divEl is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
monaco.json.jsonDefaults.setDiagnosticsOptions({
|
||||||
|
validate: true,
|
||||||
|
enableSchemaRequest: true,
|
||||||
|
schemas: [
|
||||||
|
{
|
||||||
|
fileMatch: ['config.json'],
|
||||||
|
uri: `${env.docsUrl}/schemas/config.schema.json`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
initEditor(monaco);
|
||||||
|
errorDebug();
|
||||||
|
editor = monaco.editor.create(divElement, editorOptions);
|
||||||
|
aiPromptManager.setEditor(editor);
|
||||||
|
decorationsCollection = editor.createDecorationsCollection([]);
|
||||||
|
|
||||||
|
editor.onMouseDown((e) => {
|
||||||
|
const isGutter = e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN;
|
||||||
|
if (isGutter && e.target.position?.lineNumber === lastMouseLine && lastMouseLine > 0) {
|
||||||
|
e.event.preventDefault();
|
||||||
|
e.event.stopPropagation();
|
||||||
|
toggleAIPopup(e.target.position.lineNumber);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(({ isFlush }) => {
|
||||||
|
const newText = editor?.getValue();
|
||||||
|
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentText = newText;
|
||||||
|
onUpdate(currentText);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onMouseMove((e) => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (showPopup) return;
|
||||||
|
if (editor.getModel()?.id !== mermaidModel.id) return;
|
||||||
|
|
||||||
|
lastMouseLine = e.target.position?.lineNumber ?? 0;
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onMouseLeave(() => {
|
||||||
|
lastMouseLine = 0;
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyEditorTheme(mode.current);
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
editor?.layout({
|
||||||
|
height: entries[0].contentRect.height,
|
||||||
|
width: entries[0].contentRect.width
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (divElement.parentElement) {
|
||||||
|
resizeObserver.observe(divElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
jsonModel.dispose();
|
||||||
|
mermaidModel.dispose();
|
||||||
|
aiPromptManager.destroy();
|
||||||
|
editor?.dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = editorMode === 'code' ? mermaidModel : jsonModel;
|
||||||
|
|
||||||
|
if (editor.getModel()?.id !== model.id) {
|
||||||
|
editor.setModel(model);
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear decorations if not in 'code' mode, or if the model changes
|
||||||
|
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
|
||||||
|
decorationsCollection?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update editor text if it's different
|
||||||
|
const newText = editorMode === 'code' ? code : mermaid;
|
||||||
|
if (newText !== currentText) {
|
||||||
|
isUpdatingFromState = true;
|
||||||
|
try {
|
||||||
|
editor.setScrollTop(0);
|
||||||
|
editor.pushUndoStop();
|
||||||
|
editor.executeEdits('updateCode', [
|
||||||
|
{
|
||||||
|
range: model.getFullModelRange(),
|
||||||
|
text: newText
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
editor.pushUndoStop();
|
||||||
|
currentText = newText;
|
||||||
|
} finally {
|
||||||
|
isUpdatingFromState = false;
|
||||||
|
}
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display/clear errors
|
||||||
|
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative h-full grow overflow-hidden">
|
||||||
|
<div bind:this={divElement} id="editor" class="h-full w-full"></div>
|
||||||
|
<div bind:this={aiPromptPopupElement}>
|
||||||
|
<AIPromptPopup
|
||||||
|
show={showPopup}
|
||||||
|
bind:input
|
||||||
|
onHeightChange={(height) => aiPromptManager.updateHeight(height)}
|
||||||
|
onClose={closePopup}
|
||||||
|
onTryFree={() => {
|
||||||
|
logMermaidChartClick('vibeDiagramming');
|
||||||
|
window.open(
|
||||||
|
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
|
||||||
|
'_blank',
|
||||||
|
'noopener'
|
||||||
|
);
|
||||||
|
closePopup();
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.suggestion-icon) {
|
||||||
|
background-color: #e8eaf9;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
margin-left: 4px;
|
||||||
|
background-image: url('/icons/use-chat.svg');
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(#editor.mermaid-dark .suggestion-icon) {
|
||||||
|
background-color: #2e4d6b;
|
||||||
|
background-image: url('/icons/use-chat-dark.svg');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { TID } from '$/constants';
|
||||||
|
import type { DocumentationConfig } from '$/types';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { standardizeDiagramType } from '$/util/mermaid';
|
||||||
|
import { validatedState } from '$/util/state.svelte';
|
||||||
|
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||||
|
|
||||||
|
const docURLBase = env.docsUrl;
|
||||||
|
const docMap = {
|
||||||
|
architecture: {
|
||||||
|
code: '/syntax/architecture.html'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
code: '/syntax/block.html'
|
||||||
|
},
|
||||||
|
c4: {
|
||||||
|
code: '/syntax/c4.html'
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
code: '/syntax/classDiagram.html',
|
||||||
|
config: '/syntax/classDiagram.html#configuration'
|
||||||
|
},
|
||||||
|
er: {
|
||||||
|
code: '/syntax/entityRelationshipDiagram.html',
|
||||||
|
config: '/syntax/entityRelationshipDiagram.html#styling'
|
||||||
|
},
|
||||||
|
flowchart: {
|
||||||
|
code: '/syntax/flowchart.html',
|
||||||
|
config: '/syntax/flowchart.html#configuration'
|
||||||
|
},
|
||||||
|
gantt: {
|
||||||
|
code: '/syntax/gantt.html',
|
||||||
|
config: '/syntax/gantt.html#configuration'
|
||||||
|
},
|
||||||
|
gitGraph: {
|
||||||
|
code: '/syntax/gitgraph.html',
|
||||||
|
config: '/syntax/gitgraph.html#gitgraph-specific-configuration-options'
|
||||||
|
},
|
||||||
|
journey: {
|
||||||
|
code: '/syntax/userJourney.html'
|
||||||
|
},
|
||||||
|
kanban: {
|
||||||
|
code: '/syntax/kanban.html',
|
||||||
|
config: '/syntax/kanban.html#configuration-options'
|
||||||
|
},
|
||||||
|
mindmap: {
|
||||||
|
code: '/syntax/mindmap.html'
|
||||||
|
},
|
||||||
|
packet: {
|
||||||
|
code: '/syntax/packet.html',
|
||||||
|
config: '/config/schema-docs/config-defs-packet-diagram-config.html'
|
||||||
|
},
|
||||||
|
pie: {
|
||||||
|
code: '/syntax/pie.html',
|
||||||
|
config: '/syntax/pie.html#configuration'
|
||||||
|
},
|
||||||
|
quadrantChart: {
|
||||||
|
code: '/syntax/quadrantChart.html',
|
||||||
|
config: '/syntax/quadrantChart.html#chart-configurations'
|
||||||
|
},
|
||||||
|
requirement: {
|
||||||
|
code: '/syntax/requirementDiagram.html'
|
||||||
|
},
|
||||||
|
sankey: {
|
||||||
|
code: '/syntax/sankey.html',
|
||||||
|
config: '/syntax/sankey.html#configuration'
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
code: '/syntax/sequenceDiagram.html',
|
||||||
|
config: '/syntax/sequenceDiagram.html#configuration'
|
||||||
|
},
|
||||||
|
stateDiagram: {
|
||||||
|
code: '/syntax/stateDiagram.html'
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
code: '/syntax/timeline.html',
|
||||||
|
config: '/syntax/timeline.html#themes'
|
||||||
|
},
|
||||||
|
treemap: {
|
||||||
|
code: '/syntax/treemap.html',
|
||||||
|
config: '/syntax/treemap.html#configuration-options'
|
||||||
|
},
|
||||||
|
xychart: {
|
||||||
|
code: '/syntax/xyChart.html',
|
||||||
|
config: '/syntax/xyChart.html#chart-configurations'
|
||||||
|
},
|
||||||
|
zenuml: {
|
||||||
|
code: '/syntax/zenuml.html'
|
||||||
|
}
|
||||||
|
} as const satisfies DocumentationConfig;
|
||||||
|
|
||||||
|
const doc = $derived.by(() => {
|
||||||
|
const { editorMode, diagramType } = validatedState.current;
|
||||||
|
if (!diagramType) {
|
||||||
|
return { key: '', url: docURLBase };
|
||||||
|
}
|
||||||
|
const key = standardizeDiagramType(diagramType);
|
||||||
|
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
|
||||||
|
code: ''
|
||||||
|
};
|
||||||
|
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
|
||||||
|
return { key, url };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={TID.diagramDocumentationButton}
|
||||||
|
href={doc.url}
|
||||||
|
target="_blank"
|
||||||
|
title="View documentation for {doc.key.replace('Diagram', '')} diagram">
|
||||||
|
<BookIcon />
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import * as Popover from '$/components/ui/popover';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: { title: string; href: string }[];
|
||||||
|
icon?: Component;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let props: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger class="flex items-center gap-0">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<props.icon class={props.class} />
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<ul tabindex="0" class="flex flex-col">
|
||||||
|
{#each props.links as { href, title } (title)}
|
||||||
|
<li class="rounded-md p-2 hover:bg-muted">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="0"
|
||||||
|
class="whitespace-nowrap underline"
|
||||||
|
target="_blank"
|
||||||
|
{href}>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -1,133 +1,83 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
Cypress: boolean;
|
|
||||||
editorLoaded: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { EditorMode } from '$lib/types';
|
import DesktopEditor from '$/components/DesktopEditor.svelte';
|
||||||
import { stateStore, updateCode, updateConfig } from '$lib/util/state';
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
import { themeStore } from '$lib/util/theme';
|
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||||
import { errorDebug, syncDiagram } from '$lib/util/util';
|
import MobileEditor from '$/components/MobileEditor.svelte';
|
||||||
import * as monaco from 'monaco-editor';
|
import { Button } from '$/components/ui/button';
|
||||||
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
import { TID } from '$/constants';
|
||||||
import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
import { env } from '$/util/env';
|
||||||
import { onMount } from 'svelte';
|
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
|
||||||
import { initEditor } from '$lib/util/monacoExtra';
|
import { logMermaidChartClick } from '$lib/util/stats';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { debounce } from 'lodash-es';
|
||||||
|
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
|
||||||
|
|
||||||
let divElement: HTMLDivElement | undefined;
|
const { isMobile } = $props<{ isMobile: boolean }>();
|
||||||
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
const onUpdate = (text: string) => {
|
||||||
let editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
|
if (validatedState.current.editorMode === 'code') {
|
||||||
minimap: {
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
theme: 'mermaid',
|
|
||||||
overviewRulerLanes: 0
|
|
||||||
};
|
|
||||||
let text = '';
|
|
||||||
|
|
||||||
stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
|
|
||||||
// console.log('editor store subscription', { code, mermaid });
|
|
||||||
if (!editor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update editor text if it's different
|
|
||||||
const newText = editorMode === 'code' ? code : mermaid;
|
|
||||||
if (newText !== text) {
|
|
||||||
// console.log('updating editor text', newText);
|
|
||||||
editor.setScrollTop(0);
|
|
||||||
editor.setValue(newText);
|
|
||||||
text = newText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update editor mode if it's different
|
|
||||||
const language = editorMode === 'code' ? 'mermaid' : 'json';
|
|
||||||
const model = editor.getModel();
|
|
||||||
if (!model) {
|
|
||||||
console.error("editor model doesn't exist");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (model.getLanguageId() !== language) {
|
|
||||||
monaco.editor.setModelLanguage(model, language);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display/clear errors
|
|
||||||
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
|
|
||||||
});
|
|
||||||
|
|
||||||
themeStore.subscribe(({ isDark }) => {
|
|
||||||
editor && monaco.editor.setTheme(isDark ? 'mermaid-dark' : 'mermaid');
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUpdate = (text: string, mode: EditorMode) => {
|
|
||||||
// console.log('editor HandleUpdate', { text, mode });
|
|
||||||
if (mode === 'code') {
|
|
||||||
updateCode(text);
|
updateCode(text);
|
||||||
} else {
|
} else {
|
||||||
updateConfig(text);
|
updateConfig(text);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
let showError = $state(false);
|
||||||
self.MonacoEnvironment = {
|
|
||||||
getWorker(_, label) {
|
|
||||||
if (label === 'json') {
|
|
||||||
return new monacoJsonWorker();
|
|
||||||
}
|
|
||||||
return new monacoEditorWorker();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!divElement) {
|
const showErrorDebounced = debounce(() => {
|
||||||
throw new Error('divEl is undefined');
|
showError = true;
|
||||||
}
|
}, 3000);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
initEditor(monaco);
|
|
||||||
errorDebug();
|
|
||||||
editor = monaco.editor.create(divElement, editorOptions);
|
|
||||||
editor.onDidChangeModelContent(({ isFlush }) => {
|
|
||||||
const newText = editor?.getValue();
|
|
||||||
if (!newText || text === newText || isFlush) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
text = newText;
|
|
||||||
handleUpdate(text, $stateStore.editorMode);
|
|
||||||
});
|
|
||||||
editor.addAction({
|
|
||||||
id: 'mermaid-render-diagram',
|
|
||||||
label: 'Render Diagram',
|
|
||||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
|
|
||||||
run: function () {
|
|
||||||
syncDiagram();
|
|
||||||
logEvent('renderDiagram', {
|
|
||||||
method: 'keyboardShortcut'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
monaco.editor.setTheme($themeStore.isDark ? 'mermaid-dark' : 'mermaid');
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
|
||||||
editor?.layout({
|
|
||||||
height: entries[0].contentRect.height,
|
|
||||||
width: entries[0].contentRect.width
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (divElement.parentElement) {
|
$effect(() => {
|
||||||
resizeObserver.observe(divElement.parentElement);
|
if (validatedState.current.error) {
|
||||||
|
showErrorDebounced();
|
||||||
|
} else {
|
||||||
|
showErrorDebounced.cancel();
|
||||||
|
showError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.Cypress) {
|
|
||||||
window.editorLoaded = true;
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
editor?.dispose();
|
showErrorDebounced.cancel();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={divElement} id="editor" class="overflow-hidden" />
|
<div class="flex h-full flex-col">
|
||||||
|
{#if isMobile}
|
||||||
|
<MobileEditor {onUpdate} />
|
||||||
|
{:else}
|
||||||
|
<DesktopEditor {onUpdate} />
|
||||||
|
{/if}
|
||||||
|
{#if showError && validatedState.current.error instanceof Error}
|
||||||
|
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
|
||||||
|
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
|
||||||
|
<div class="flex w-fit items-center gap-2">
|
||||||
|
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<p>Syntax error</p>
|
||||||
|
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
|
||||||
|
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
|
||||||
|
Create a free account to repair with AI
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if validatedState.current.editorMode === 'code'}
|
||||||
|
<McWrapper>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
data-testid={TID.aiRepairButton}
|
||||||
|
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
|
||||||
|
target="_blank"
|
||||||
|
onclick={() => logMermaidChartClick('aiRepair')}>
|
||||||
|
<MermaidChartIcon />
|
||||||
|
AI Repair
|
||||||
|
</Button>
|
||||||
|
</McWrapper>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
|
||||||
|
<pre>{validatedState.current.error?.toString()}</pre>
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
|
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { standardizeDiagramType } from '$/util/mermaid';
|
||||||
|
import { validatedState, urls } from '$/util/state.svelte';
|
||||||
|
import { logMermaidChartClick } from '$/util/stats';
|
||||||
|
import { quintInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
const visualEditDiagramTypes = new Set([
|
||||||
|
'flowchart',
|
||||||
|
'stateDiagram',
|
||||||
|
'classDiagram',
|
||||||
|
'sequenceDiagram',
|
||||||
|
'er',
|
||||||
|
'requirement',
|
||||||
|
'mindmap'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diagramType = $derived.by(() => {
|
||||||
|
const dt = validatedState.current.diagramType;
|
||||||
|
return dt ? standardizeDiagramType(dt) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showVisualEdit = $derived.by(() => {
|
||||||
|
return diagramType ? visualEditDiagramTypes.has(diagramType) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EnhancedEditAction {
|
||||||
|
campaign: string;
|
||||||
|
label: string;
|
||||||
|
medium: 'ai_edit' | 'visual_edit' | 'voice_edit';
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleIntervalMs = 30_000;
|
||||||
|
|
||||||
|
let currentActionIndex = $state(0);
|
||||||
|
|
||||||
|
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
|
||||||
|
if (!validatedState.current.diagramType) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: EnhancedEditAction[] = [
|
||||||
|
{ campaign: 'voice_1', label: 'with voice', medium: 'voice_edit', source: 'voiceEdit' },
|
||||||
|
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showVisualEdit) {
|
||||||
|
actions.unshift({
|
||||||
|
campaign: 'visual_1',
|
||||||
|
label: 'visually',
|
||||||
|
medium: 'visual_edit',
|
||||||
|
source: 'visualEdit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAction = $derived.by(() => {
|
||||||
|
const actions = availableActions;
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions[currentActionIndex % actions.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const actionCount = availableActions.length;
|
||||||
|
|
||||||
|
if (actionCount === 0) {
|
||||||
|
currentActionIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentActionIndex >= actionCount) {
|
||||||
|
currentActionIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionCount <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalID = setInterval(() => {
|
||||||
|
currentActionIndex = (currentActionIndex + 1) % actionCount;
|
||||||
|
}, cycleIntervalMs);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalID);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if currentAction}
|
||||||
|
<McWrapper>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
href={urls.current.mermaidChart({
|
||||||
|
medium: currentAction.medium,
|
||||||
|
campaign: currentAction.campaign
|
||||||
|
}).save}
|
||||||
|
target="_blank"
|
||||||
|
onclick={() => logMermaidChartClick(currentAction.source)}>
|
||||||
|
<MermaidChartIcon />
|
||||||
|
Edit
|
||||||
|
{#key currentAction.label}
|
||||||
|
<span
|
||||||
|
class="-ml-1"
|
||||||
|
in:slide={{ axis: 'x', easing: quintInOut, delay: 400 }}
|
||||||
|
out:slide={{ axis: 'x', easing: quintInOut }}>
|
||||||
|
{currentAction.label}
|
||||||
|
</span>
|
||||||
|
{/key}
|
||||||
|
</Button>
|
||||||
|
</McWrapper>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { validatedState } from '$/util/state.svelte';
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import type { ComponentProps, Snippet } from 'svelte';
|
||||||
|
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
domain,
|
||||||
|
shouldCheckDiagramType = true,
|
||||||
|
side = 'bottom',
|
||||||
|
labelPrefix = 'Opens your diagram in',
|
||||||
|
isVisible = true,
|
||||||
|
sharesData = true,
|
||||||
|
showPopup = true
|
||||||
|
}: {
|
||||||
|
children: Snippet;
|
||||||
|
domain: string;
|
||||||
|
shouldCheckDiagramType?: boolean;
|
||||||
|
side?: ComponentProps<typeof Tooltip.Content>['side'];
|
||||||
|
labelPrefix?: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
sharesData?: boolean;
|
||||||
|
showPopup?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let shouldDisableComponent = $derived(
|
||||||
|
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isVisible}
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={100}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
{#if showPopup}
|
||||||
|
<Tooltip.Content {side} class="bg-secondary shadow-xl">
|
||||||
|
<div
|
||||||
|
class="flex cursor-help items-center gap-2"
|
||||||
|
title={sharesData
|
||||||
|
? 'Your diagram will be sent to the external service'
|
||||||
|
: 'Your diagram is not shared'}>
|
||||||
|
{#if shouldDisableComponent}
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
This diagram type is not supported in {domain}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
{labelPrefix}
|
||||||
|
<div class="text-accent">{domain}</div>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tooltip.Content>
|
||||||
|
{/if}
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-12 items-center justify-between gap-2 rounded-2xl bg-muted p-3">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
Exports a `waitForFontAwesomeToLoad` function that waits for FontAwesome to load.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import FontAwesome from '$lib/client/components/FontAwesome';
|
||||||
|
let waitForFontAwesomeToLoad: FontAwesome["waitForFontAwesomeToLoad"];
|
||||||
|
</script>
|
||||||
|
<FontAwesome bind:waitForFontAwesomeToLoad />
|
||||||
|
```
|
||||||
|
-->
|
||||||
|
<script context="module" lang="ts">
|
||||||
|
/**
|
||||||
|
* Returns `true` if the code may contain FontAwesome icons, and we should
|
||||||
|
* wait for the fonts to load before rendering.
|
||||||
|
*/
|
||||||
|
export function mayContainFontAwesome(code: string) {
|
||||||
|
// taken from https://github.com/mermaid-js/mermaid/blob/7043892e871d0c413ec63dc1570a8ef738d15568/packages/mermaid/src/diagrams/flowchart/flowRenderer-v2.js#L63
|
||||||
|
// Not ideal, since we're looking at unparsed code.
|
||||||
|
const regex = /fa[blrs]?:fa-[\w-]+/g;
|
||||||
|
return regex.test(code);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Vite will automatically take care of adding this to our `<head>`
|
||||||
|
let lazyLoadFontAwesomeCSS = import('./FontAwesomeCSS.svelte');
|
||||||
|
|
||||||
|
let fontsLoaded = (async () => {
|
||||||
|
await lazyLoadFontAwesomeCSS;
|
||||||
|
|
||||||
|
// Once the stylesheet has been parsed, the fonts will be in document.fonts
|
||||||
|
// TODO: Maybe we should scan the CSS style sheet only, and only load FontAwesome fonts
|
||||||
|
await Promise.allSettled(Array.from(document.fonts, (font) => font.load()));
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for FontAwesome to load.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when FontAwesome is
|
||||||
|
* loaded, or there was an error that we ignore.
|
||||||
|
*/
|
||||||
|
export async function waitForFontAwesomeToLoad() {
|
||||||
|
return await fontsLoaded;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await lazyLoadFontAwesomeCSS then FontAwesomeCSS}
|
||||||
|
<FontAwesomeCSS.default />
|
||||||
|
{/await}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
Imports the FontAwesome CSS file.
|
||||||
|
|
||||||
|
Needed, since the `@sveltejs/adapter-node` plugin doesn't seem to support
|
||||||
|
lazy-importing CSS, even though `@sveltejs/adapter-vercel` is fine with it!
|
||||||
|
|
||||||
|
However, making a `.svelte` file that imports the CSS file, and then
|
||||||
|
lazy-loading the `.svelte` file works.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.css';
|
||||||
|
</script>
|
||||||
@@ -1,45 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from '$lib/components/Card/Card.svelte';
|
import Card from '$lib/components/Card/Card.svelte';
|
||||||
import { inputStateStore, getStateString } from '$lib/util/state';
|
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
|
||||||
import {
|
|
||||||
addHistoryEntry,
|
|
||||||
historyModeStore,
|
|
||||||
clearHistoryData,
|
|
||||||
getPreviousState,
|
|
||||||
historyStore,
|
|
||||||
loaderHistoryStore,
|
|
||||||
restoreHistory
|
|
||||||
} from './history';
|
|
||||||
import { notify, prompt } from '$lib/util/notify';
|
import { notify, prompt } from '$lib/util/notify';
|
||||||
import { onMount } from 'svelte';
|
import { serializeState } from '$lib/util/serde';
|
||||||
import { get } from 'svelte/store';
|
import { inputState, replaceInputState } from '$lib/util/state.svelte';
|
||||||
|
import { logEvent } from '$lib/util/stats';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
|
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
|
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
|
||||||
|
import DownloadIcon from '~icons/material-symbols/download-rounded';
|
||||||
|
import SaveIcon from '~icons/material-symbols/save-outline-rounded';
|
||||||
|
import UndoIcon from '~icons/material-symbols/settings-backup-restore-rounded';
|
||||||
|
import UploadIcon from '~icons/material-symbols/upload-rounded';
|
||||||
|
import HistoryIcon from '~icons/mdi/clock-outline';
|
||||||
|
import GitAltIcon from '~icons/mdi/git';
|
||||||
|
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Separator } from '../ui/separator';
|
||||||
|
import {
|
||||||
|
addManualEntry,
|
||||||
|
clearActive,
|
||||||
|
historyState,
|
||||||
|
removeEntry,
|
||||||
|
restoreEntries,
|
||||||
|
setMode
|
||||||
|
} from './historyState.svelte';
|
||||||
|
|
||||||
dayjs.extend(dayjsRelativeTime);
|
dayjs.extend(dayjsRelativeTime);
|
||||||
|
|
||||||
const HISTORY_SAVE_INTERVAL = 60_000;
|
const baseTabs: Tab[] = [
|
||||||
|
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
|
||||||
const tabSelectHandler = (message: CustomEvent<Tab>) => {
|
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
|
||||||
historyModeStore.set(message.detail.id as HistoryType);
|
|
||||||
};
|
|
||||||
let tabs: Tab[] = [
|
|
||||||
{
|
|
||||||
id: 'manual',
|
|
||||||
title: 'Saved',
|
|
||||||
icon: 'far fa-bookmark'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'auto',
|
|
||||||
title: 'Timeline',
|
|
||||||
icon: 'fas fa-history'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
|
||||||
|
|
||||||
|
const tabs = $derived(
|
||||||
|
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Surface revisions once when they first appear; the user can switch away after.
|
||||||
|
let revisionsShown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
|
||||||
|
revisionsShown = true;
|
||||||
|
setMode('loader');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyMessage = $derived(
|
||||||
|
historyState.mode === 'auto'
|
||||||
|
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
|
||||||
|
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabSelectHandler = (tab: Tab) => {
|
||||||
|
setMode(tab.id as HistoryType);
|
||||||
|
};
|
||||||
|
|
||||||
const downloadHistory = () => {
|
const downloadHistory = () => {
|
||||||
const data = get(historyStore);
|
const data = historyState.entries;
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -47,9 +67,7 @@
|
|||||||
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
|
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
logEvent('history', {
|
logEvent('history', { action: 'download' });
|
||||||
action: 'download'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadHistory = () => {
|
const uploadHistory = () => {
|
||||||
@@ -62,100 +80,79 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: HistoryEntry[] = JSON.parse(await file.text());
|
const data: HistoryEntry[] = JSON.parse(await file.text());
|
||||||
restoreHistory(data);
|
const { restored, invalid, duplicates } = restoreEntries(data);
|
||||||
|
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
|
||||||
});
|
});
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveHistory = (auto = false) => {
|
const saveHistory = () => {
|
||||||
const currentState: string = getStateString();
|
if (!addManualEntry($state.snapshot(inputState))) {
|
||||||
const previousState: string = getPreviousState(auto);
|
|
||||||
if (previousState !== currentState) {
|
|
||||||
addHistoryEntry({
|
|
||||||
state: $inputStateStore,
|
|
||||||
time: Date.now(),
|
|
||||||
type: auto ? 'auto' : 'manual'
|
|
||||||
});
|
|
||||||
} else if (!auto) {
|
|
||||||
notify('State already saved.');
|
notify('State already saved.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearHistory = (id?: string): void => {
|
const clearAll = () => {
|
||||||
if (!id && !prompt('Clear all saved items?')) {
|
if (prompt('Clear all saved items?')) {
|
||||||
return;
|
clearActive();
|
||||||
}
|
}
|
||||||
clearHistoryData(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreHistoryItem = (state: State): void => {
|
const restoreHistoryItem = (state: State): void => {
|
||||||
inputStateStore.set({ ...state, updateDiagram: true });
|
replaceInputState({ ...state, updateDiagram: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const relativeTime = (time: number) => {
|
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
|
||||||
const t = new Date(time);
|
const entryUrl = (state: State): string =>
|
||||||
return `${new Date(t).toLocaleString()} (${dayjs(t).fromNow()})`;
|
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
// Serialize each entry's URL once per change rather than per row on every render.
|
||||||
historyModeStore.set('manual');
|
const entriesWithUrl = $derived(
|
||||||
setInterval(() => {
|
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
|
||||||
saveHistory(true);
|
);
|
||||||
}, HISTORY_SAVE_INTERVAL);
|
|
||||||
});
|
|
||||||
|
|
||||||
loaderHistoryStore.subscribe((entries) => {
|
|
||||||
if (entries.length > 0 && tabs.length === 2) {
|
|
||||||
tabs = [
|
|
||||||
{
|
|
||||||
id: 'loader',
|
|
||||||
title: 'Revisions',
|
|
||||||
icon: 'fab fa-git-alt'
|
|
||||||
},
|
|
||||||
...tabs
|
|
||||||
];
|
|
||||||
historyModeStore.set('loader');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card on:select={tabSelectHandler} bind:isOpen {tabs} title="History">
|
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
|
||||||
<div slot="actions">
|
{#snippet actions()}
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
id="uploadHistory"
|
id="uploadHistory"
|
||||||
class="btn btn-secondary btn-xs w-12"
|
onclick={uploadHistory}
|
||||||
on:click|stopPropagation={() => uploadHistory()}
|
title="Upload history"><UploadIcon /></Button>
|
||||||
title="Upload history"><i class="fa fa-upload" /></button>
|
{#if historyState.entries.length > 0}
|
||||||
{#if $historyStore.length > 0}
|
<Button
|
||||||
<button
|
|
||||||
id="downloadHistory"
|
id="downloadHistory"
|
||||||
class="btn btn-secondary btn-xs w-12"
|
size="icon"
|
||||||
on:click|stopPropagation={() => downloadHistory()}
|
variant="ghost"
|
||||||
title="Download history"><i class="fa fa-download" /></button>
|
onclick={downloadHistory}
|
||||||
|
title="Download history"><DownloadIcon /></Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
<Separator orientation="vertical" />
|
||||||
<button
|
<Button
|
||||||
id="saveHistory"
|
id="saveHistory"
|
||||||
class="btn btn-success btn-xs w-12"
|
size="icon"
|
||||||
on:click|stopPropagation={() => saveHistory()}
|
variant="ghost"
|
||||||
title="Save current state"><i class="far fa-save" /></button>
|
onclick={saveHistory}
|
||||||
{#if $historyModeStore !== 'loader'}
|
title="Save current state"><SaveIcon /></Button>
|
||||||
<button
|
{#if historyState.mode !== 'loader'}
|
||||||
|
<Button
|
||||||
id="clearHistory"
|
id="clearHistory"
|
||||||
class="btn btn-error btn-xs w-12"
|
size="icon"
|
||||||
on:click|stopPropagation={() => clearHistory()}
|
variant="ghost"
|
||||||
title="Delete all saved states"><i class="fas fa-trash-alt" /></button>
|
class="hover:text-destructive"
|
||||||
|
onclick={clearAll}
|
||||||
|
title="Delete all saved states"><TrashAltIcon /></Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ul class="h-56 space-y-2 overflow-auto p-2" id="historyList">
|
{/snippet}
|
||||||
{#if $historyStore.length > 0}
|
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
|
||||||
{#each $historyStore as { id, state, time, name, url, type }}
|
{#if entriesWithUrl.length > 0}
|
||||||
<li class="flex-col rounded p-2 shadow">
|
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
|
||||||
<div class="flex">
|
<li class="flex flex-col gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-col text-base-content">
|
<div class="flex flex-col">
|
||||||
{#if url}
|
{#if url}
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@@ -163,28 +160,50 @@
|
|||||||
title="Open revision in new tab"
|
title="Open revision in new tab"
|
||||||
class="text-blue-500 hover:underline">{name}</a>
|
class="text-blue-500 hover:underline">{name}</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{name}</span>
|
<span class="whitespace-nowrap">{name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-sm text-gray-400">{relativeTime(time)}</span>
|
<span class="text-xs whitespace-nowrap text-primary-foreground/30">
|
||||||
|
{new Date(time).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex content-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button class="btn btn-success" on:click={() => restoreHistoryItem(state)}
|
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
|
||||||
><i class="fas fa-undo mr-1" />Restore</button>
|
{dayjs(time).fromNow()}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
href={openUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Open in new tab">
|
||||||
|
<OpenInNewIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Restore this version"
|
||||||
|
onclick={() => restoreHistoryItem(state)}>
|
||||||
|
<UndoIcon />
|
||||||
|
</Button>
|
||||||
{#if type !== 'loader'}
|
{#if type !== 'loader'}
|
||||||
<button class="btn btn-error" on:click={() => clearHistory(id)}
|
<Button
|
||||||
><i class="fas fa-trash-alt mr-1" />Delete</button>
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
class="hover:text-destructive"
|
||||||
|
title="Delete this version"
|
||||||
|
onclick={() => removeEntry(id)}>
|
||||||
|
<TrashAltIcon />
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="m-2">
|
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
|
||||||
No items in History<br />
|
|
||||||
Click the Save button to save current state and restore it later.<br />
|
|
||||||
Timeline will automatically be saved every minute.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
|
||||||
import type { HistoryEntry } from '$lib/types';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
addHistoryEntry,
|
|
||||||
injectHistoryIDs,
|
|
||||||
clearHistoryData,
|
|
||||||
historyModeStore,
|
|
||||||
historyStore
|
|
||||||
} from './history';
|
|
||||||
import { defaultState } from '$lib/util/state';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
describe('history', () => {
|
|
||||||
it('should handle saving individual history entry', () => {
|
|
||||||
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
|
|
||||||
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
|
|
||||||
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 12_345,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [manualEntry] = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
|
|
||||||
expect(manualEntry.time).toBe(12_345);
|
|
||||||
expect(manualEntry.type).toBe('manual');
|
|
||||||
expect(manualEntry.name).not.toBeNull();
|
|
||||||
expect(manualEntry.state).not.toBeNull();
|
|
||||||
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 54_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [autoEntry] = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
|
|
||||||
expect(autoEntry.time).toBe(54_321);
|
|
||||||
expect(autoEntry.type).toBe('auto');
|
|
||||||
expect(autoEntry.name).not.toBeNull();
|
|
||||||
expect(autoEntry.state).not.toBeNull();
|
|
||||||
|
|
||||||
historyModeStore.set('manual');
|
|
||||||
clearHistoryData();
|
|
||||||
historyModeStore.set('auto');
|
|
||||||
clearHistoryData();
|
|
||||||
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
|
|
||||||
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear history entries', () => {
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 12_345,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
addHistoryEntry({
|
|
||||||
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
|
|
||||||
time: 123_456,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
historyModeStore.set('manual');
|
|
||||||
const store: HistoryEntry[] = get(historyStore);
|
|
||||||
expect(store.length).toBe(2);
|
|
||||||
clearHistoryData(store[1].id);
|
|
||||||
expect(get(historyStore).length).toBe(1);
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
|
|
||||||
historyModeStore.set('auto');
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 54_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
addHistoryEntry({
|
|
||||||
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
|
|
||||||
time: 654_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
expect(get(historyStore).length).toBe(2);
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
// Test calling when history is empty
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history migration', () => {
|
|
||||||
it('should inject history IDs as migration', () => {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'manualHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"helpful-ocean"}]'
|
|
||||||
);
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'autoHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
|
|
||||||
);
|
|
||||||
let manualHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
let autoHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
|
|
||||||
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
|
|
||||||
|
|
||||||
injectHistoryIDs();
|
|
||||||
|
|
||||||
manualHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
autoHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
|
|
||||||
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { derived, writable, get } from 'svelte/store';
|
|
||||||
import type { Readable, Writable } from 'svelte/store';
|
|
||||||
import { persist, localStorage } from '$lib/util/persist';
|
|
||||||
import { generateSlug } from 'random-word-slugs';
|
|
||||||
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
|
|
||||||
import { v4 as uuidV4 } from 'uuid';
|
|
||||||
import { logEvent } from '$lib/util/stats';
|
|
||||||
|
|
||||||
const MAX_AUTO_HISTORY_LENGTH = 30;
|
|
||||||
|
|
||||||
export const historyModeStore: Writable<HistoryType> = persist(
|
|
||||||
writable('manual'),
|
|
||||||
localStorage(),
|
|
||||||
'autoHistoryMode'
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
|
|
||||||
writable([]),
|
|
||||||
localStorage(),
|
|
||||||
'autoHistoryStore'
|
|
||||||
);
|
|
||||||
|
|
||||||
const manualHistoryStore: Writable<HistoryEntry[]> = persist(
|
|
||||||
writable([]),
|
|
||||||
localStorage(),
|
|
||||||
'manualHistoryStore'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
|
|
||||||
|
|
||||||
export const historyStore: Readable<HistoryEntry[]> = derived(
|
|
||||||
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
|
|
||||||
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
|
|
||||||
switch (historyMode) {
|
|
||||||
case 'auto': {
|
|
||||||
set(autoHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'manual': {
|
|
||||||
set(manualHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'loader': {
|
|
||||||
set(loadedHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
set(autoHistories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void => {
|
|
||||||
const entry: HistoryEntry = {
|
|
||||||
...entryToAdd,
|
|
||||||
id: uuidV4()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (entry.type === 'loader') {
|
|
||||||
loaderHistoryStore.update((entries) => [entry, ...entries]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.name) {
|
|
||||||
entry.name = generateSlug(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === 'auto') {
|
|
||||||
autoHistoryStore.update((entries) => {
|
|
||||||
if (entries.length >= MAX_AUTO_HISTORY_LENGTH) {
|
|
||||||
entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1);
|
|
||||||
}
|
|
||||||
return [entry, ...entries];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
manualHistoryStore.update((entries) => [entry, ...entries]);
|
|
||||||
logEvent('history', { action: 'save' });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearHistoryData = (idToClear?: string): void => {
|
|
||||||
(get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => {
|
|
||||||
if (get(historyModeStore) !== 'loader') {
|
|
||||||
entries = entries.filter(({ id }) => idToClear && id != idToClear);
|
|
||||||
logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' });
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPreviousState = (auto: boolean): string => {
|
|
||||||
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
|
|
||||||
if (entries.length > 0) {
|
|
||||||
return JSON.stringify(entries[0].state);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const restoreHistory = (data: HistoryEntry[]) => {
|
|
||||||
const entries = data.filter((element) => validateEntry(element));
|
|
||||||
const invalidEntryCount = data.length - entries.length;
|
|
||||||
if (invalidEntryCount > 0) {
|
|
||||||
console.error(`${invalidEntryCount} invalid history entries were removed.`);
|
|
||||||
console.error(data);
|
|
||||||
}
|
|
||||||
if (entries.length > 0) {
|
|
||||||
let entryCount = 0;
|
|
||||||
(entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => {
|
|
||||||
const existingIDs = new Set(existing.map(({ id }) => id));
|
|
||||||
const newEntries = entries.filter(({ id }) => !existingIDs.has(id));
|
|
||||||
entryCount = newEntries.length;
|
|
||||||
const combined = [...existing, ...newEntries];
|
|
||||||
combined.sort((a, b) => b.time - a.time);
|
|
||||||
return combined;
|
|
||||||
});
|
|
||||||
|
|
||||||
alert(
|
|
||||||
`${entryCount} entries restored. ${invalidEntryCount} invalid, ${
|
|
||||||
entries.length - entryCount
|
|
||||||
} duplicates.`
|
|
||||||
);
|
|
||||||
logEvent('history', {
|
|
||||||
action: 'restore',
|
|
||||||
success: entryCount,
|
|
||||||
invalid: invalidEntryCount,
|
|
||||||
duplicates: entries.length - entryCount
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('No valid entries found.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const setIDs = (entries: HistoryEntry[]) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.id) {
|
|
||||||
entry.id = uuidV4();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const injectHistoryIDs = (): void => {
|
|
||||||
autoHistoryStore.update(setIDs);
|
|
||||||
manualHistoryStore.update(setIDs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateEntry = (entry: HistoryEntry): boolean => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-expect-error
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
return entry.type && entry.state && entry.time && true;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
|
||||||
|
import { persisted, readJSON, type Persisted } from '$lib/util/persist.svelte';
|
||||||
|
import { inputState } from '$lib/util/state.svelte';
|
||||||
|
import { logEvent } from '$lib/util/stats';
|
||||||
|
import { generateSlug } from 'random-word-slugs';
|
||||||
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
|
|
||||||
|
const MAX_AUTO_HISTORY_LENGTH = 30;
|
||||||
|
const AUTO_SAVE_INTERVAL = 60_000;
|
||||||
|
|
||||||
|
const auto = persisted<HistoryEntry[]>('autoHistoryStore', []);
|
||||||
|
const manual = persisted<HistoryEntry[]>('manualHistoryStore', []);
|
||||||
|
const mode = persisted<HistoryType>('autoHistoryMode', 'manual');
|
||||||
|
let loader = $state<HistoryEntry[]>([]);
|
||||||
|
|
||||||
|
// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload.
|
||||||
|
if (mode.value === 'loader') {
|
||||||
|
mode.value = 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The persisted slot backing a mode; loader is in-memory and has no slot.
|
||||||
|
const slotFor = (m: HistoryType): Persisted<HistoryEntry[]> | null => {
|
||||||
|
switch (m) {
|
||||||
|
case 'auto': {
|
||||||
|
return auto;
|
||||||
|
}
|
||||||
|
case 'manual': {
|
||||||
|
return manual;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const historyState = {
|
||||||
|
get entries(): HistoryEntry[] {
|
||||||
|
return slotFor(mode.value)?.value ?? loader;
|
||||||
|
},
|
||||||
|
get loaderEntries(): HistoryEntry[] {
|
||||||
|
return loader;
|
||||||
|
},
|
||||||
|
get mode(): HistoryType {
|
||||||
|
return mode.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMode = (next: HistoryType): void => {
|
||||||
|
mode.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dedup key: only the fields that define the diagram, so volatile/view-only
|
||||||
|
// fields (renderCount, pan/zoom, …) don't count as a change.
|
||||||
|
export const stateKey = (state: State): string =>
|
||||||
|
JSON.stringify({ code: state.code, mermaid: state.mermaid });
|
||||||
|
|
||||||
|
const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({
|
||||||
|
id: uuidV4(),
|
||||||
|
name: generateSlug(2),
|
||||||
|
state,
|
||||||
|
time: Date.now(),
|
||||||
|
type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns true if added, false if it duplicated the most recent entry.
|
||||||
|
const addEntry = (
|
||||||
|
slot: Persisted<HistoryEntry[]>,
|
||||||
|
state: State,
|
||||||
|
type: 'auto' | 'manual',
|
||||||
|
maxLength?: number
|
||||||
|
): boolean => {
|
||||||
|
const entries = slot.value;
|
||||||
|
if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmed =
|
||||||
|
maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries;
|
||||||
|
slot.value = [createEntry(state, type), ...trimmed];
|
||||||
|
logEvent('history', { action: 'save', type });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual');
|
||||||
|
|
||||||
|
export const addAutoEntry = (state: State): boolean =>
|
||||||
|
addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH);
|
||||||
|
|
||||||
|
// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids.
|
||||||
|
export const setLoaderEntries = (entries: Optional<HistoryEntry, 'id'>[]): void => {
|
||||||
|
loader = entries.map((entry) =>
|
||||||
|
entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeEntry = (id: string): void => {
|
||||||
|
const slot = slotFor(mode.value);
|
||||||
|
if (!slot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot.value = slot.value.filter((entry) => entry.id !== id);
|
||||||
|
logEvent('history', { action: 'clear', type: 'single' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearActive = (): void => {
|
||||||
|
const slot = slotFor(mode.value);
|
||||||
|
if (!slot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot.value = [];
|
||||||
|
logEvent('history', { action: 'clear', type: 'all' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEntry = (entry: HistoryEntry): boolean =>
|
||||||
|
Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number';
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
restored: number;
|
||||||
|
invalid: number;
|
||||||
|
duplicates: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes each uploaded entry to the store matching its own type, skipping ids
|
||||||
|
// that already exist.
|
||||||
|
export const restoreEntries = (data: HistoryEntry[]): RestoreResult => {
|
||||||
|
const valid = data.filter((entry) => validateEntry(entry));
|
||||||
|
const invalid = data.length - valid.length;
|
||||||
|
let restored = 0;
|
||||||
|
|
||||||
|
const slots: [HistoryType, Persisted<HistoryEntry[]>][] = [
|
||||||
|
['auto', auto],
|
||||||
|
['manual', manual]
|
||||||
|
];
|
||||||
|
for (const [type, slot] of slots) {
|
||||||
|
const incoming = valid.filter((entry) => entry.type === type);
|
||||||
|
if (incoming.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingIDs = slot.value.map(({ id }) => id);
|
||||||
|
const fresh = incoming.filter(({ id }) => !existingIDs.includes(id));
|
||||||
|
restored += fresh.length;
|
||||||
|
slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = valid.length - restored;
|
||||||
|
logEvent('history', { action: 'restore', duplicates, invalid, success: restored });
|
||||||
|
return { restored, invalid, duplicates };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setIDs = (entries: HistoryEntry[]): HistoryEntry[] =>
|
||||||
|
entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() }));
|
||||||
|
|
||||||
|
// One-time migration: re-reads localStorage so entries written by an older
|
||||||
|
// version get ids, then persists and updates the reactive state.
|
||||||
|
export const injectHistoryIDs = (): void => {
|
||||||
|
auto.value = setIDs(readJSON<HistoryEntry[]>('autoHistoryStore', []));
|
||||||
|
manual.value = setIDs(readJSON<HistoryEntry[]>('manualHistoryStore', []));
|
||||||
|
};
|
||||||
|
|
||||||
|
let autoSaveTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
// Idempotent; returns the stop function for use as a lifecycle cleanup.
|
||||||
|
export const startAutoSave = (): (() => void) => {
|
||||||
|
if (autoSaveTimer === undefined) {
|
||||||
|
autoSaveTimer = setInterval(
|
||||||
|
() => addAutoEntry($state.snapshot(inputState)),
|
||||||
|
AUTO_SAVE_INTERVAL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stopAutoSave;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopAutoSave = (): void => {
|
||||||
|
if (autoSaveTimer !== undefined) {
|
||||||
|
clearInterval(autoSaveTimer);
|
||||||
|
autoSaveTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import type { HistoryEntry } from '$lib/types';
|
||||||
|
import { defaultState, replaceInputState } from '$lib/util/state.svelte';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
addAutoEntry,
|
||||||
|
addManualEntry,
|
||||||
|
clearActive,
|
||||||
|
historyState,
|
||||||
|
injectHistoryIDs,
|
||||||
|
removeEntry,
|
||||||
|
restoreEntries,
|
||||||
|
setLoaderEntries,
|
||||||
|
setMode,
|
||||||
|
startAutoSave,
|
||||||
|
stateKey,
|
||||||
|
stopAutoSave
|
||||||
|
} from './historyState.svelte';
|
||||||
|
|
||||||
|
const codeState = (code: string) => ({ ...defaultState, code });
|
||||||
|
|
||||||
|
/** Read the entries currently shown for a given mode. */
|
||||||
|
const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => {
|
||||||
|
setMode(mode);
|
||||||
|
return historyState.entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset every store through the public API so tests don't leak into each other.
|
||||||
|
setMode('manual');
|
||||||
|
clearActive();
|
||||||
|
setMode('auto');
|
||||||
|
clearActive();
|
||||||
|
setLoaderEntries([]);
|
||||||
|
setMode('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stateKey', () => {
|
||||||
|
it('ignores volatile and view-only fields, keying only on code + config', () => {
|
||||||
|
const a = {
|
||||||
|
...defaultState,
|
||||||
|
code: 'graph TD\n A-->B',
|
||||||
|
panZoom: true,
|
||||||
|
renderCount: 1,
|
||||||
|
updateDiagram: true
|
||||||
|
};
|
||||||
|
const b = {
|
||||||
|
...defaultState,
|
||||||
|
code: 'graph TD\n A-->B',
|
||||||
|
pan: { x: 5, y: 5 },
|
||||||
|
panZoom: false,
|
||||||
|
renderCount: 99,
|
||||||
|
updateDiagram: false
|
||||||
|
};
|
||||||
|
expect(stateKey(a)).toBe(stateKey(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when code differs', () => {
|
||||||
|
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
|
||||||
|
stateKey(codeState('graph TD\n A-->C'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when config differs', () => {
|
||||||
|
const a = { ...defaultState, mermaid: '{"theme":"dark"}' };
|
||||||
|
const b = { ...defaultState, mermaid: '{"theme":"forest"}' };
|
||||||
|
expect(stateKey(a)).not.toBe(stateKey(b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addManualEntry', () => {
|
||||||
|
it('adds to the manual store only, never the auto store', () => {
|
||||||
|
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(1);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and does not add a duplicate of the latest entry', () => {
|
||||||
|
const state = codeState('graph TD\n A-->B');
|
||||||
|
expect(addManualEntry(state)).toBe(true);
|
||||||
|
expect(addManualEntry(state)).toBe(false);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats states differing only in volatile/view fields as duplicates', () => {
|
||||||
|
expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
addManualEntry({
|
||||||
|
...defaultState,
|
||||||
|
code: 'graph TD\n A-->B',
|
||||||
|
panZoom: false,
|
||||||
|
renderCount: 2,
|
||||||
|
updateDiagram: true
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a new entry when the code changes', () => {
|
||||||
|
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||||
|
expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates an id and a name for each entry', () => {
|
||||||
|
addManualEntry(codeState('graph TD\n A-->B'));
|
||||||
|
const [entry] = entriesFor('manual');
|
||||||
|
expect(entry.id).toBeTruthy();
|
||||||
|
expect(entry.name).toBeTruthy();
|
||||||
|
expect(entry.type).toBe('manual');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addAutoEntry', () => {
|
||||||
|
it('adds to the auto store only, never the manual store', () => {
|
||||||
|
expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(1);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and does not add a duplicate of the latest entry', () => {
|
||||||
|
const state = codeState('graph TD\n A-->B');
|
||||||
|
expect(addAutoEntry(state)).toBe(true);
|
||||||
|
expect(addAutoEntry(state)).toBe(false);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the auto store at 30 entries, dropping the oldest', () => {
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
addAutoEntry(codeState(`graph TD\n A-->B${i}`));
|
||||||
|
}
|
||||||
|
const entries = entriesFor('auto');
|
||||||
|
expect(entries).toHaveLength(30);
|
||||||
|
expect(entries[0].state.code).toBe('graph TD\n A-->B34');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('historyState.entries', () => {
|
||||||
|
it('reflects the active mode', () => {
|
||||||
|
addManualEntry(codeState('manual-code'));
|
||||||
|
addAutoEntry(codeState('auto-code'));
|
||||||
|
|
||||||
|
setMode('manual');
|
||||||
|
expect(historyState.entries).toHaveLength(1);
|
||||||
|
expect(historyState.entries[0].state.code).toBe('manual-code');
|
||||||
|
|
||||||
|
setMode('auto');
|
||||||
|
expect(historyState.entries).toHaveLength(1);
|
||||||
|
expect(historyState.entries[0].state.code).toBe('auto-code');
|
||||||
|
|
||||||
|
setMode('loader');
|
||||||
|
expect(historyState.entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeEntry / clearActive', () => {
|
||||||
|
it('removes a single entry from the active store by id', () => {
|
||||||
|
addManualEntry(codeState('graph TD\n A-->B'));
|
||||||
|
addManualEntry(codeState('graph TD\n A-->C'));
|
||||||
|
setMode('manual');
|
||||||
|
const target = historyState.entries[1].id;
|
||||||
|
removeEntry(target);
|
||||||
|
expect(historyState.entries).toHaveLength(1);
|
||||||
|
expect(historyState.entries.some((e) => e.id === target)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all entries in the active store only', () => {
|
||||||
|
addManualEntry(codeState('graph TD\n A-->B'));
|
||||||
|
addAutoEntry(codeState('graph TD\n A-->C'));
|
||||||
|
setMode('manual');
|
||||||
|
clearActive();
|
||||||
|
expect(entriesFor('manual')).toHaveLength(0);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing in loader mode', () => {
|
||||||
|
setLoaderEntries([
|
||||||
|
{ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }
|
||||||
|
]);
|
||||||
|
setMode('loader');
|
||||||
|
clearActive();
|
||||||
|
expect(historyState.entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setLoaderEntries', () => {
|
||||||
|
it('replaces the in-memory revisions and assigns ids', () => {
|
||||||
|
setLoaderEntries([
|
||||||
|
{ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' },
|
||||||
|
{ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }
|
||||||
|
]);
|
||||||
|
setMode('loader');
|
||||||
|
expect(historyState.entries).toHaveLength(2);
|
||||||
|
expect(historyState.entries.every((e) => e.id)).toBe(true);
|
||||||
|
|
||||||
|
setLoaderEntries([
|
||||||
|
{ name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' }
|
||||||
|
]);
|
||||||
|
expect(historyState.entries).toHaveLength(1);
|
||||||
|
expect(historyState.entries[0].name).toBe('only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreEntries', () => {
|
||||||
|
it('routes each entry to the store matching its own type', () => {
|
||||||
|
const result = restoreEntries([
|
||||||
|
{ id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' },
|
||||||
|
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }
|
||||||
|
]);
|
||||||
|
expect(result.restored).toBe(2);
|
||||||
|
expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']);
|
||||||
|
expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duplicates by id and reports them', () => {
|
||||||
|
restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]);
|
||||||
|
const result = restoreEntries([
|
||||||
|
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
|
||||||
|
{ id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' }
|
||||||
|
]);
|
||||||
|
expect(result.restored).toBe(1);
|
||||||
|
expect(result.duplicates).toBe(1);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid entries and does not restore them', () => {
|
||||||
|
const result = restoreEntries([
|
||||||
|
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
|
||||||
|
{ foo: 'bar' } as unknown as HistoryEntry
|
||||||
|
]);
|
||||||
|
expect(result.restored).toBe(1);
|
||||||
|
expect(result.invalid).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts restored entries newest first', () => {
|
||||||
|
restoreEntries([
|
||||||
|
{ id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' },
|
||||||
|
{ id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' },
|
||||||
|
{ id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' }
|
||||||
|
]);
|
||||||
|
expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => {
|
||||||
|
const result = restoreEntries([
|
||||||
|
{ id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' }
|
||||||
|
]);
|
||||||
|
expect(result.restored).toBe(1);
|
||||||
|
expect(result.invalid).toBe(0);
|
||||||
|
expect(entriesFor('manual')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('injectHistoryIDs migration', () => {
|
||||||
|
it('adds ids to persisted entries that lack them', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'manualHistoryStore',
|
||||||
|
'[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]'
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'autoHistoryStore',
|
||||||
|
'[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]'
|
||||||
|
);
|
||||||
|
injectHistoryIDs();
|
||||||
|
const manual = JSON.parse(
|
||||||
|
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
||||||
|
) as HistoryEntry[];
|
||||||
|
const auto = JSON.parse(
|
||||||
|
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
||||||
|
) as HistoryEntry[];
|
||||||
|
expect(manual).toHaveLength(1);
|
||||||
|
expect(auto).toHaveLength(1);
|
||||||
|
expect(manual.every(({ id }) => id !== undefined)).toBe(true);
|
||||||
|
expect(auto.every(({ id }) => id !== undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-save lifecycle', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
stopAutoSave();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an auto entry on each interval from the current editor state', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
replaceInputState(codeState('graph TD\n auto-saved'));
|
||||||
|
startAutoSave();
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
const entries = entriesFor('auto');
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].state.code).toBe('graph TD\n auto-saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: calling startAutoSave twice does not double-record', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
replaceInputState(codeState('graph TD\n once'));
|
||||||
|
startAutoSave();
|
||||||
|
startAutoSave();
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops recording after stopAutoSave', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
replaceInputState(codeState('graph TD\n stoppable'));
|
||||||
|
startAutoSave();
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
stopAutoSave();
|
||||||
|
replaceInputState(codeState('graph TD\n after-stop'));
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(entriesFor('auto')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
|
import * as Popover from '$/components/ui/popover';
|
||||||
|
import { Switch } from '$/components/ui/switch';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { urls } from '$/util/state.svelte';
|
||||||
|
import { logMermaidChartClick } from '$/util/stats';
|
||||||
|
import { cn } from '$/utils';
|
||||||
|
import { mode, setMode } from 'mode-watcher';
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
import MermaidTailIcon from '~icons/custom/mermaid-tail';
|
||||||
|
import AddIcon from '~icons/material-symbols/add-2-rounded';
|
||||||
|
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||||
|
import DuplicateIcon from '~icons/material-symbols/content-copy-outline-rounded';
|
||||||
|
import ContrastIcon from '~icons/material-symbols/contrast';
|
||||||
|
import PluginIcon from '~icons/material-symbols/electrical-services-rounded';
|
||||||
|
import MenuIcon from '~icons/material-symbols/menu-rounded';
|
||||||
|
import CommunityIcon from '~icons/material-symbols/person-play-outline-rounded';
|
||||||
|
import PlaygroundIcon from '~icons/material-symbols/shape-line-outline';
|
||||||
|
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
icon: Component;
|
||||||
|
href: string;
|
||||||
|
class?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
sharesData?: boolean;
|
||||||
|
checkDiagramType?: boolean;
|
||||||
|
isSectionEnd?: boolean;
|
||||||
|
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = $derived([
|
||||||
|
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
|
||||||
|
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
|
||||||
|
{
|
||||||
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
|
||||||
|
icon: PlaygroundIcon,
|
||||||
|
isSectionEnd: true,
|
||||||
|
label: 'Edit in Playground',
|
||||||
|
onclick: () => logMermaidChartClick('editInPlayground'),
|
||||||
|
renderer: mcMenuItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mermaid.js',
|
||||||
|
icon: MermaidTailIcon,
|
||||||
|
href: env.docsUrl,
|
||||||
|
renderer: menuItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Documentation',
|
||||||
|
icon: BookIcon,
|
||||||
|
href: `${env.docsUrl}/intro/`,
|
||||||
|
renderer: menuItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Community',
|
||||||
|
icon: CommunityIcon,
|
||||||
|
href: 'https://discord.gg/sKeNQX4Wtj',
|
||||||
|
renderer: menuItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checkDiagramType: false,
|
||||||
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
|
||||||
|
icon: PluginIcon,
|
||||||
|
label: 'Plugins',
|
||||||
|
onclick: () => logMermaidChartClick('plugins'),
|
||||||
|
renderer: mcMenuItem,
|
||||||
|
sharesData: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '#',
|
||||||
|
icon: ContrastIcon,
|
||||||
|
isSectionEnd: true,
|
||||||
|
label: 'Dark Mode',
|
||||||
|
renderer: darkModeMenuItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checkDiagramType: false,
|
||||||
|
class: 'text-accent border-b-0',
|
||||||
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
|
||||||
|
icon: MermaidChartIcon,
|
||||||
|
label: 'Mermaid',
|
||||||
|
onclick: () => logMermaidChartClick('mermaidHome'),
|
||||||
|
renderer: mcMenuItem,
|
||||||
|
sharesData: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||||
|
<a
|
||||||
|
href={options.href}
|
||||||
|
target="_blank"
|
||||||
|
onclick={options.onclick}
|
||||||
|
class={cn(
|
||||||
|
'flex items-center justify-start gap-2 border-b-2 p-2 px-3 hover:bg-muted',
|
||||||
|
options.isSectionEnd && 'border-border-dark',
|
||||||
|
options.class
|
||||||
|
)}>
|
||||||
|
<options.icon class="size-5" />
|
||||||
|
{options.label}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
|
||||||
|
<McWrapper
|
||||||
|
side="right"
|
||||||
|
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
|
||||||
|
sharesData={item.sharesData}
|
||||||
|
shouldCheckDiagramType={item.checkDiagramType}>
|
||||||
|
{@render menuItem(item)}
|
||||||
|
</McWrapper>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
|
||||||
|
options.isSectionEnd && 'border-border-dark',
|
||||||
|
options.class
|
||||||
|
)}>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<ContrastIcon />
|
||||||
|
Dark Mode
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={mode.current === 'dark'}
|
||||||
|
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger class="shrink-0">
|
||||||
|
<MenuIcon class="size-6" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="start" class="flex flex-col overflow-hidden border-2 p-0" sideOffset={16}>
|
||||||
|
{#each menuItems as { renderer, ...item } (item.label)}
|
||||||
|
{@render renderer(item)}
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { isOnMermaidAI } from '$/util/migration/domainMigration';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<
|
||||||
|
ComponentProps<typeof ExternalLinkWrapper>,
|
||||||
|
'isVisible' | 'domain' | 'showPopup'
|
||||||
|
> = $props();
|
||||||
|
|
||||||
|
const mermaidChartDomain = 'mermaid.ai';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ExternalLinkWrapper
|
||||||
|
{...props}
|
||||||
|
domain={mermaidChartDomain}
|
||||||
|
isVisible={env.isEnabledMermaidChartLinks}
|
||||||
|
showPopup={!isOnMermaidAI()}>
|
||||||
|
{@render children()}
|
||||||
|
</ExternalLinkWrapper>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { asset } from '$app/paths';
|
||||||
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
|
let { class: className }: { class?: ClassValue } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { EditorProps } from '$/types';
|
||||||
|
import { validatedState } from '$/util/state.svelte';
|
||||||
|
import { json, jsonLanguage } from '@codemirror/lang-json';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
|
import { yamlFrontmatter } from '@codemirror/lang-yaml';
|
||||||
|
import { language } from '@codemirror/language';
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { vsCodeDark } from '@fsegurai/codemirror-theme-vscode-dark';
|
||||||
|
import { vsCodeLight } from '@fsegurai/codemirror-theme-vscode-light';
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let editorView: EditorView | undefined;
|
||||||
|
let editorContainer: HTMLDivElement;
|
||||||
|
// Deliberately not $state: the sync effect below both reads and writes it,
|
||||||
|
// so a reactive currentText would make every keystroke re-run the effect
|
||||||
|
// against the not-yet-revalidated state and revert the user's input.
|
||||||
|
let currentText = '';
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
|
||||||
|
const { onUpdate }: EditorProps = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
editorView?.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editorView = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: currentText,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
languageCompartment.of([]),
|
||||||
|
themeCompartment.of([]),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
const newText = update.state.doc.toString();
|
||||||
|
if (currentText === newText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentText = newText;
|
||||||
|
onUpdate(newText);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.theme({
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
'&.cm-editor': {
|
||||||
|
height: '100%'
|
||||||
|
},
|
||||||
|
'&.cm-scroller': {
|
||||||
|
overflow: 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
parent: editorContainer
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editorView?.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { editorMode, code, mermaid } = validatedState.current;
|
||||||
|
const text = editorMode === 'code' ? code : mermaid;
|
||||||
|
if (currentText === text || !editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentText = text;
|
||||||
|
editorView.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: editorView.state.doc.length,
|
||||||
|
insert: text
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const stateLanguage = editorView.state.facet(language);
|
||||||
|
const isStateJson = stateLanguage === jsonLanguage;
|
||||||
|
const isCodeJson = editorMode === 'config';
|
||||||
|
if (stateLanguage && isStateJson === isCodeJson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorView.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(
|
||||||
|
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={editorContainer} class="size-full"></div>
|
||||||
@@ -1,173 +1,102 @@
|
|||||||
<script context="module" lang="ts">
|
<script lang="ts" module>
|
||||||
|
import { logEvent, logMermaidChartClick } from '$lib/util/stats';
|
||||||
import { version } from 'mermaid/package.json';
|
import { version } from 'mermaid/package.json';
|
||||||
import { logEvent, plausible } from '$lib/util/stats';
|
|
||||||
void logEvent('version', {
|
void logEvent('version', {
|
||||||
mermaidVersion: version
|
mermaidVersion: version
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Theme from './Theme.svelte';
|
import { resolve } from '$app/paths';
|
||||||
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
|
import MainMenu from '$/components/MainMenu.svelte';
|
||||||
import Privacy from './Privacy.svelte';
|
import { Button } from '$/components/ui/button';
|
||||||
let isMenuOpen = false;
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
|
||||||
|
import { untrack, type ComponentProps, type Snippet } from 'svelte';
|
||||||
|
import MermaidIcon from '~icons/custom/mermaid';
|
||||||
|
import CloseIcon from '~icons/material-symbols/close-rounded';
|
||||||
|
import GithubIcon from '~icons/mdi/github';
|
||||||
|
import DropdownNavMenu from './DropdownNavMenu.svelte';
|
||||||
|
|
||||||
function toggleMenu() {
|
interface Props {
|
||||||
isMenuOpen = !isMenuOpen;
|
mobileToggle?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
hidePromotion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Link {
|
let { children, mobileToggle, hidePromotion = false }: Props = $props();
|
||||||
href: string;
|
|
||||||
title?: string;
|
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
|
||||||
icon?: string;
|
|
||||||
img?: string;
|
const githubLinks: Links = [
|
||||||
}
|
{ title: 'Mermaid JS', href: 'https://github.com/mermaid-js/mermaid' },
|
||||||
const links: Link[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Documentation',
|
title: 'Mermaid Live Editor',
|
||||||
href: 'https://mermaid.js.org/intro/getting-started.html'
|
href: 'https://github.com/mermaid-js/mermaid-live-editor'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tutorial',
|
title: 'Mermaid CLI',
|
||||||
href: 'https://mermaid.js.org/ecosystem/tutorials.html'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Mermaid',
|
|
||||||
href: 'https://github.com/mermaid-js/mermaid'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'CLI',
|
|
||||||
href: 'https://github.com/mermaid-js/mermaid-cli'
|
href: 'https://github.com/mermaid-js/mermaid-cli'
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://github.com/mermaid-js/mermaid-live-editor',
|
|
||||||
icon: 'fab fa-github fa-lg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://mermaidchart.com',
|
|
||||||
img: './mermaidchart-logo.svg'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let activePromotion = getActivePromotion();
|
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
|
||||||
|
|
||||||
const trackBannerClick = () => {
|
const trackBannerClick = () => {
|
||||||
if (!plausible || !activePromotion) {
|
if (!activePromotion) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logEvent('bannerClick', {
|
logEvent('bannerClick', {
|
||||||
promotion: activePromotion.id
|
promotion: activePromotion.id
|
||||||
});
|
});
|
||||||
|
logMermaidChartClick('banner');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if activePromotion}
|
{#if activePromotion}
|
||||||
|
<div class="top-bar z-10 flex h-fit w-full bg-primary">
|
||||||
<div
|
<div
|
||||||
class="top-bar z-10 flex h-fit w-full items-center justify-center bg-gradient-to-r from-[#bd34fe] to-[#ff3670] p-1 text-center text-white">
|
class="flex grow"
|
||||||
<div
|
|
||||||
class="flex flex-grow"
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:click={trackBannerClick}
|
onclick={trackBannerClick}
|
||||||
on:keypress={trackBannerClick}>
|
onkeypress={trackBannerClick}>
|
||||||
<svelte:component this={activePromotion.component} />
|
<activePromotion.component {closeBanner} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
{#snippet closeBanner()}
|
||||||
|
<Button
|
||||||
title="Dismiss banner"
|
title="Dismiss banner"
|
||||||
on:click={() => {
|
variant="ghost"
|
||||||
|
class="hover:bg-transparent hover:text-[#261A56]"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
dismissPromotion(activePromotion?.id);
|
dismissPromotion(activePromotion?.id);
|
||||||
activePromotion = undefined;
|
activePromotion = undefined;
|
||||||
}}>
|
}}>
|
||||||
<i class="fa fa-close px-2" />
|
<CloseIcon />
|
||||||
</button>
|
</Button>
|
||||||
|
{/snippet}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="navbar bg-primary p-0 shadow-lg">
|
<nav class="z-50 flex p-4 sm:p-6">
|
||||||
<div class="mx-2 flex-1 px-2">
|
<div class="flex flex-1 items-center gap-2">
|
||||||
<span class="text-lg font-bold">
|
<MainMenu />
|
||||||
<a href="/">Mermaid<span class="text-xs font-thin">v{version}</span> Live Editor</a>
|
<MermaidIcon class="size-6" />
|
||||||
</span>
|
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
|
||||||
</div>
|
{#if !mobileToggle}
|
||||||
|
Mermaid
|
||||||
<label
|
|
||||||
for="menu-toggle"
|
|
||||||
class={isMenuOpen ? 'hidden' : 'pointer-cursor fixed right-4 z-[1000] lg:hidden'}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
class="fill-current"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20">
|
|
||||||
<title>Menu</title>
|
|
||||||
<path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z" />
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Cross SVG -->
|
|
||||||
<label
|
|
||||||
for="menu-toggle"
|
|
||||||
class={isMenuOpen ? 'pointer-cursor fixed right-4 z-[1000] lg:hidden' : 'hidden'}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
class="fill-current"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20">
|
|
||||||
<title>Cross</title>
|
|
||||||
<line x1="5" y1="5" x2="15" y2="15" stroke="white" stroke-width="2" />
|
|
||||||
<line x1="5" y1="15" x2="15" y2="5" stroke="white" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
class="hidden"
|
|
||||||
type="checkbox"
|
|
||||||
id="menu-toggle"
|
|
||||||
bind:checked={isMenuOpen}
|
|
||||||
on:click={toggleMenu} />
|
|
||||||
|
|
||||||
<div class="hidden w-full lg:flex lg:w-auto lg:items-center" id="menu">
|
|
||||||
<Theme />
|
|
||||||
<ul class="items-center justify-between pt-4 text-base lg:flex lg:pt-0">
|
|
||||||
<li>
|
|
||||||
<Privacy />
|
|
||||||
</li>
|
|
||||||
{#each links as { title, href, icon, img }}
|
|
||||||
<li>
|
|
||||||
<a class="btn btn-ghost" target="_blank" {href}>
|
|
||||||
{#if icon}
|
|
||||||
<i class={icon} />
|
|
||||||
{:else if img}
|
|
||||||
<img src={img} alt={title} />
|
|
||||||
{/if}
|
|
||||||
{#if title}
|
|
||||||
{title}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
Live Editor
|
||||||
</a>
|
</a>
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="menu"
|
||||||
|
class="hidden flex-nowrap items-center justify-between gap-3 overflow-hidden md:flex">
|
||||||
|
<DropdownNavMenu icon={GithubIcon} links={githubLinks} />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
{@render mobileToggle?.()}
|
||||||
<style>
|
</nav>
|
||||||
#menu-toggle:checked + #menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 2.5rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
background: #661ae6;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import type { PanZoomState } from '$/util/panZoom';
|
||||||
|
import { urls } from '$/util/state.svelte';
|
||||||
|
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
|
||||||
|
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
|
||||||
|
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
|
||||||
|
import MagnifyingGlassMinusIcon from '~icons/material-symbols/zoom-out';
|
||||||
|
|
||||||
|
let { panZoomState }: { panZoomState: PanZoomState } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FloatingToolbar>
|
||||||
|
<Button variant="ghost" size="icon" title="Reset view" onclick={() => panZoomState.reset()}>
|
||||||
|
<ArrowsToCircleIcon />
|
||||||
|
</Button>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="hidden sm:block"
|
||||||
|
onclick={() => panZoomState.zoomOut()}>
|
||||||
|
<MagnifyingGlassMinusIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="hidden sm:block" onclick={() => panZoomState.zoomIn()}>
|
||||||
|
<MagnifyingGlassPlusIcon />
|
||||||
|
</Button>
|
||||||
|
<Separator orientation="vertical" class="hidden sm:block" />
|
||||||
|
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
|
||||||
|
<ExpandIcon />
|
||||||
|
</Button>
|
||||||
|
</FloatingToolbar>
|
||||||
@@ -1,148 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { updateCode } from '$lib/util/state';
|
import Card from '$/components/Card/Card.svelte';
|
||||||
import Card from '$lib/components/Card/Card.svelte';
|
import { Button, buttonVariants } from '$/components/ui/button';
|
||||||
|
import * as Popover from '$/components/ui/popover';
|
||||||
|
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
|
||||||
|
import { updateCode } from '$lib/util/state.svelte';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { logEvent } from '$lib/util/stats';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
|
||||||
|
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
|
||||||
|
|
||||||
const samples = {
|
const extras: Record<string, SampleExample[]> = {
|
||||||
Flow: `flowchart TD
|
ZenUML: [
|
||||||
A[Christmas] -->|Get money| B(Go shopping)
|
{
|
||||||
B --> C{Let me think}
|
title: 'Order Service',
|
||||||
C -->|One| D[Laptop]
|
isDefault: true,
|
||||||
C -->|Two| E[iPhone]
|
code: `zenuml
|
||||||
C -->|Three| F[fa:fa-car Car]`,
|
|
||||||
Sequence: `sequenceDiagram
|
|
||||||
Alice->>+John: Hello John, how are you?
|
|
||||||
Alice->>+John: John, can you hear me?
|
|
||||||
John-->>-Alice: Hi Alice, I can hear you!
|
|
||||||
John-->>-Alice: I feel great!`,
|
|
||||||
Class: `classDiagram
|
|
||||||
Animal <|-- Duck
|
|
||||||
Animal <|-- Fish
|
|
||||||
Animal <|-- Zebra
|
|
||||||
Animal : +int age
|
|
||||||
Animal : +String gender
|
|
||||||
Animal: +isMammal()
|
|
||||||
Animal: +mate()
|
|
||||||
class Duck{
|
|
||||||
+String beakColor
|
|
||||||
+swim()
|
|
||||||
+quack()
|
|
||||||
}
|
|
||||||
class Fish{
|
|
||||||
-int sizeInFeet
|
|
||||||
-canEat()
|
|
||||||
}
|
|
||||||
class Zebra{
|
|
||||||
+bool is_wild
|
|
||||||
+run()
|
|
||||||
}`,
|
|
||||||
State: `stateDiagram-v2
|
|
||||||
[*] --> Still
|
|
||||||
Still --> [*]
|
|
||||||
Still --> Moving
|
|
||||||
Moving --> Still
|
|
||||||
Moving --> Crash
|
|
||||||
Crash --> [*]`,
|
|
||||||
Gantt: `gantt
|
|
||||||
title A Gantt Diagram
|
|
||||||
dateFormat YYYY-MM-DD
|
|
||||||
section Section
|
|
||||||
A task :a1, 2014-01-01, 30d
|
|
||||||
Another task :after a1 , 20d
|
|
||||||
section Another
|
|
||||||
Task in sec :2014-01-12 , 12d
|
|
||||||
another task : 24d`,
|
|
||||||
Pie: `pie title Pets adopted by volunteers
|
|
||||||
"Dogs" : 386
|
|
||||||
"Cats" : 85
|
|
||||||
"Rats" : 15`,
|
|
||||||
ER: `erDiagram
|
|
||||||
CUSTOMER }|..|{ DELIVERY-ADDRESS : has
|
|
||||||
CUSTOMER ||--o{ ORDER : places
|
|
||||||
CUSTOMER ||--o{ INVOICE : "liable for"
|
|
||||||
DELIVERY-ADDRESS ||--o{ ORDER : receives
|
|
||||||
INVOICE ||--|{ ORDER : covers
|
|
||||||
ORDER ||--|{ ORDER-ITEM : includes
|
|
||||||
PRODUCT-CATEGORY ||--|{ PRODUCT : contains
|
|
||||||
PRODUCT ||--o{ ORDER-ITEM : "ordered in"`,
|
|
||||||
'User Journey': `journey
|
|
||||||
title My working day
|
|
||||||
section Go to work
|
|
||||||
Make tea: 5: Me
|
|
||||||
Go upstairs: 3: Me
|
|
||||||
Do work: 1: Me, Cat
|
|
||||||
section Go home
|
|
||||||
Go downstairs: 5: Me
|
|
||||||
Sit down: 3: Me`,
|
|
||||||
Git: `gitGraph
|
|
||||||
commit
|
|
||||||
commit
|
|
||||||
branch develop
|
|
||||||
checkout develop
|
|
||||||
commit
|
|
||||||
commit
|
|
||||||
checkout main
|
|
||||||
merge develop
|
|
||||||
commit
|
|
||||||
commit`,
|
|
||||||
Mindmap: `mindmap
|
|
||||||
root((mindmap))
|
|
||||||
Origins
|
|
||||||
Long history
|
|
||||||
::icon(fa fa-book)
|
|
||||||
Popularisation
|
|
||||||
British popular psychology author Tony Buzan
|
|
||||||
Research
|
|
||||||
On effectivness<br/>and features
|
|
||||||
On Automatic creation
|
|
||||||
Uses
|
|
||||||
Creative techniques
|
|
||||||
Strategic planning
|
|
||||||
Argument mapping
|
|
||||||
Tools
|
|
||||||
Pen and paper
|
|
||||||
Mermaid`,
|
|
||||||
QuadrantChart: `quadrantChart
|
|
||||||
title Reach and engagement of campaigns
|
|
||||||
x-axis Low Reach --> High Reach
|
|
||||||
y-axis Low Engagement --> High Engagement
|
|
||||||
quadrant-1 We should expand
|
|
||||||
quadrant-2 Need to promote
|
|
||||||
quadrant-3 Re-evaluate
|
|
||||||
quadrant-4 May be improved
|
|
||||||
Campaign A: [0.3, 0.6]
|
|
||||||
Campaign B: [0.45, 0.23]
|
|
||||||
Campaign C: [0.57, 0.69]
|
|
||||||
Campaign D: [0.78, 0.34]
|
|
||||||
Campaign E: [0.40, 0.34]
|
|
||||||
Campaign F: [0.35, 0.78]`,
|
|
||||||
XYChart: `
|
|
||||||
xychart-beta
|
|
||||||
title "Sales Revenue"
|
|
||||||
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
|
|
||||||
y-axis "Revenue (in $)" 4000 --> 11000
|
|
||||||
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
|
|
||||||
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]`,
|
|
||||||
Block: `block-beta
|
|
||||||
columns 3
|
|
||||||
doc>"Document"]:3
|
|
||||||
space down1<[" "]>(down) space
|
|
||||||
|
|
||||||
block:e:3
|
|
||||||
l["left"]
|
|
||||||
m("A wide one in the middle")
|
|
||||||
r["right"]
|
|
||||||
end
|
|
||||||
space down2<[" "]>(down) space
|
|
||||||
db[("DB")]:3
|
|
||||||
space:3
|
|
||||||
D space C
|
|
||||||
db --> D
|
|
||||||
C --> db
|
|
||||||
D --> C
|
|
||||||
style m fill:#d6d,stroke:#333,stroke-width:4px
|
|
||||||
`,
|
|
||||||
ZenUML: `zenuml
|
|
||||||
title Order Service
|
title Order Service
|
||||||
@Actor Client #FFEBE6
|
@Actor Client #FFEBE6
|
||||||
@Boundary OrderController #0747A6
|
@Boundary OrderController #0747A6
|
||||||
@@ -166,48 +38,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
type SampleTypes = keyof typeof samples;
|
const samples = { ...getSampleDiagrams(), ...extras };
|
||||||
const loadSampleDiagram = (diagramType: SampleTypes): void => {
|
|
||||||
updateCode(samples[diagramType], {
|
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
|
||||||
updateDiagram: true,
|
updateCode(example.code, {
|
||||||
resetPanZoom: true
|
resetPanZoom: true,
|
||||||
|
updateDiagram: true
|
||||||
});
|
});
|
||||||
logEvent('loadSampleDiagram', { diagramType });
|
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adding in this array will add an icon to the preset menu
|
const mainDiagrams = [
|
||||||
const newDiagrams: SampleTypes[] = ['QuadrantChart', 'XYChart', 'Block', 'ZenUML'];
|
'Flowchart',
|
||||||
const diagramOrder: SampleTypes[] = [
|
|
||||||
'Flow',
|
|
||||||
'Sequence',
|
|
||||||
'Class',
|
'Class',
|
||||||
|
'Sequence',
|
||||||
|
'Entity Relationship',
|
||||||
'State',
|
'State',
|
||||||
'ER',
|
'Mindmap'
|
||||||
'Gantt',
|
];
|
||||||
'User Journey',
|
|
||||||
'Git',
|
const diagramOrder = [
|
||||||
'Pie',
|
...mainDiagrams,
|
||||||
'Mindmap',
|
...Object.keys(samples)
|
||||||
'QuadrantChart',
|
.filter((key) => !mainDiagrams.includes(key))
|
||||||
'XYChart',
|
.sort()
|
||||||
'Block',
|
|
||||||
'ZenUML'
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Sample Diagrams" isOpen={false}>
|
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
|
||||||
<div class="flex flex-wrap gap-2 p-2">
|
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
|
||||||
{#each diagramOrder as sample}
|
{#each diagramOrder as sample (sample)}
|
||||||
<button
|
{@const examples = samples[sample]}
|
||||||
class="btn btn-primary btn-sm w-fit min-w-20 flex-grow normal-case"
|
<div class="flex min-w-20 flex-grow">
|
||||||
on:click={() => loadSampleDiagram(sample)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
|
||||||
|
onclick={() => loadSampleDiagram(sample, examples[0])}>
|
||||||
{sample}
|
{sample}
|
||||||
{#if newDiagrams.includes(sample)}
|
</Button>
|
||||||
<span class="fa fa-heart ml-2" />
|
{#if examples.length > 1}
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
aria-label="Choose a {sample} example"
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ size: 'sm' }),
|
||||||
|
'rounded-l-none border-l border-primary-foreground/30 px-0.5 [&_svg]:size-5'
|
||||||
|
)}>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="start" class="flex w-fit flex-col gap-1 p-1">
|
||||||
|
{#each examples as example (example.title)}
|
||||||
|
<Popover.Close
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
||||||
|
'justify-start normal-case'
|
||||||
|
)}
|
||||||
|
onclick={() => loadSampleDiagram(sample, example)}>
|
||||||
|
{example.title}
|
||||||
|
</Popover.Close>
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,42 +1,66 @@
|
|||||||
<label for="privacyModal" class="btn btn-ghost flex gap-2">
|
<script lang="ts">
|
||||||
<i class="fa-solid fa-shield-halved text-xl"></i> Security
|
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
||||||
</label>
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { isOnMermaidLive } from '$/util/migration/domainMigration';
|
||||||
|
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
|
||||||
|
</script>
|
||||||
|
|
||||||
<input type="checkbox" id="privacyModal" class="modal-toggle" />
|
{#if env.privacyPolicyUrl}
|
||||||
<div class="modal" role="dialog">
|
<a href={env.privacyPolicyUrl} target="_blank">
|
||||||
<div class="modal-box flex flex-col gap-4 text-base-content">
|
<ShieldIcon />
|
||||||
<h1 class="text-3xl font-bold">Data security</h1>
|
|
||||||
<p>
|
|
||||||
<span class="text-xl font-extrabold">
|
|
||||||
The content of the diagrams you create never leaves your browser.
|
|
||||||
</span>
|
|
||||||
It's stored in the URL, and the browser's local storage only.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Mermaid live editor is a fully client side application, that will also work as an offline <a
|
|
||||||
href="https://web.dev/explore/progressive-web-apps"
|
|
||||||
class="link"
|
|
||||||
target="_blank">
|
|
||||||
PWA.
|
|
||||||
</a>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<ShieldIcon />
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||||
|
<ShieldIcon class="size-8 text-green-700" />
|
||||||
|
Data security
|
||||||
|
</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if isOnMermaidLive()}
|
||||||
|
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
|
||||||
|
<p>They're only stored in the URL and your browser's local storage.</p>
|
||||||
|
<p>
|
||||||
|
This is a fully open source, client-side app deployed on <a
|
||||||
|
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
|
||||||
|
class="underline"
|
||||||
|
target="_blank">GitHub Pages</a>
|
||||||
|
that works offline as a
|
||||||
|
<a href="https://web.dev/explore/progressive-web-apps" target="_blank"
|
||||||
|
>Progressive Web App</a
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The only server we have is a self hosted version of the open source and privacy friendly
|
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage
|
||||||
Plausible Analytics. We only collect data related to actions performed, like the type of
|
metadata (diagram types, feature usage, etc.). All data is <a
|
||||||
diagram rendered, number of times a feature was used, etc. <br /> All the data we collect is
|
href="https://p.mermaid.live/mermaid.live"
|
||||||
anonymized and
|
class="underline"
|
||||||
<a href="https://p.mermaid.live/mermaid.live" class="link" target="_blank">
|
target="_blank">publicly available</a
|
||||||
available publicly.
|
>.
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
<ExternalLinkWrapper domain="example.com" isVisible>
|
||||||
|
<p class="text-left">
|
||||||
|
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI",
|
||||||
|
etc) will share your diagram with those 3rd parties, and are highlighted in the UI on
|
||||||
|
hover.
|
||||||
|
</p>
|
||||||
|
</ExternalLinkWrapper>
|
||||||
|
{:else}
|
||||||
|
<p>No privacy policy has been configured for this deployment.</p>
|
||||||
<p>
|
<p>
|
||||||
Additional services like the external PNG/SVG/Kroki links and "Save to Mermaid Chart" feature
|
If you are self-hosting the Mermaid Live Editor, set the
|
||||||
will share your diagram with the respective 3rd party service.
|
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_PRIVACY_POLICY_URL</code>
|
||||||
|
environment variable at build time to link to your privacy policy, or set
|
||||||
|
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_HIDE_PRIVACY_POLICY</code>
|
||||||
|
to <code class="rounded bg-muted px-1.5 py-0.5 text-sm">true</code> to hide this button.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
<label class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" for="privacyModal">
|
</Dialog.Content>
|
||||||
X
|
</Dialog.Root>
|
||||||
</label>
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { buttonVariants } from '$/components/ui/button';
|
||||||
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { urls } from '$/util/state.svelte';
|
||||||
|
import { asset } from '$app/paths';
|
||||||
|
import ShareIcon from '~icons/material-symbols/share';
|
||||||
|
import CopyInput from './CopyInput.svelte';
|
||||||
|
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger class={buttonVariants({ size: 'sm' })}>Share</Dialog.Trigger>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||||
|
<ShareIcon class="size-5" /> Shareable links
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>Share your diagrams with others.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h2 class="flex items-center gap-2">
|
||||||
|
<img class="size-5" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
|
||||||
|
Mermaid Live Editor
|
||||||
|
</h2>
|
||||||
|
<CopyInput value={window.location.href} />
|
||||||
|
<Dialog.Description>
|
||||||
|
The content of the diagrams you create never leaves your browser.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
{#if env.isEnabledMermaidChartLinks}
|
||||||
|
<Separator />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h2 class="flex items-center gap-2">
|
||||||
|
<MermaidChartIcon class="size-5" />
|
||||||
|
Mermaid Chart Playground
|
||||||
|
</h2>
|
||||||
|
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
|
||||||
|
<Dialog.Description>
|
||||||
|
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
|
||||||
|
import { Toggle } from '$/components/ui/toggle';
|
||||||
|
import { defaultState, inputState, updateCodeStore } from '$/util/state.svelte';
|
||||||
|
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
|
||||||
|
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
|
||||||
|
|
||||||
|
if (inputState.grid === undefined) {
|
||||||
|
// Handle cases where old states were saved without grid option
|
||||||
|
updateCodeStore({ grid: defaultState.grid });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FloatingToolbar>
|
||||||
|
<Toggle
|
||||||
|
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
|
||||||
|
size="sm"
|
||||||
|
title="Hand-Drawn">
|
||||||
|
<RoughIcon />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
|
||||||
|
size="sm"
|
||||||
|
title="Background Grid">
|
||||||
|
<BackgroundIcon />
|
||||||
|
</Toggle>
|
||||||
|
</FloatingToolbar>
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { setTheme, themeStore } from '$lib/util/theme';
|
|
||||||
|
|
||||||
const themes = [
|
|
||||||
'🌝 light',
|
|
||||||
'🌚 dark',
|
|
||||||
'🧁 cupcake',
|
|
||||||
'🐝 bumblebee',
|
|
||||||
'✳️ emerald',
|
|
||||||
'🏢 corporate',
|
|
||||||
'🌃 synthwave',
|
|
||||||
'👴 retro',
|
|
||||||
'🤖 cyberpunk',
|
|
||||||
'🌸 valentine',
|
|
||||||
'🎃 halloween',
|
|
||||||
'🌷 garden',
|
|
||||||
'🌲 forest',
|
|
||||||
'🐟 aqua',
|
|
||||||
'👓 lofi',
|
|
||||||
'🖍 pastel',
|
|
||||||
'🧚♀️ fantasy',
|
|
||||||
'📝 wireframe',
|
|
||||||
'🏴 black',
|
|
||||||
'💎 luxury',
|
|
||||||
'🧛♂️ dracula'
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="dropdown hidden lg:block">
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
||||||
<div tabindex="0" class="btn btn-ghost">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="inline-block h-6 w-6 stroke-current md:mr-2"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
|
|
||||||
<span class="hidden md:inline">Theme</span>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
class="ml-1 inline-block h-4 w-4 fill-current"
|
|
||||||
><path
|
|
||||||
d="M1395 736q0 13-10 23l-466 466q-10 10-23 10t-23-10l-466-466q-10-10-10-23t10-23l50-50q10-10 23-10t23 10l393 393 393-393q10-10 23-10t23 10l50 50q10 10 10 23z" /></svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="dropdown-content top-px mt-14 h-96 w-56 overflow-y-auto bg-base-200 text-base-content shadow-2xl">
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
||||||
<ul tabindex="0" class="menu compact p-4">
|
|
||||||
{#each themes as theme}
|
|
||||||
<li class:bordered={$themeStore.theme !== undefined && theme.includes($themeStore.theme)}>
|
|
||||||
<span
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
class="btn btn-ghost justify-start"
|
|
||||||
on:click={() => setTheme(theme)}
|
|
||||||
on:keypress={() => setTheme(theme)}>{theme}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { type TransitionConfig } from 'svelte/transition';
|
||||||
|
import MoonIcon from '~icons/material-symbols/dark-mode-outline-rounded';
|
||||||
|
import SunIcon from '~icons/material-symbols/light-mode-outline-rounded';
|
||||||
|
|
||||||
|
const spin = (
|
||||||
|
node: Element,
|
||||||
|
{ duration = 400, easing = cubicInOut, clockWise = true } = {}
|
||||||
|
): TransitionConfig => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const opacity = +style.opacity;
|
||||||
|
const transform = style.transform === 'none' ? '' : style.transform;
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
easing,
|
||||||
|
css: (t, u) => `
|
||||||
|
transform: ${transform} rotate(${u * 90 * (clockWise ? 1 : -1)}deg);
|
||||||
|
opacity: ${opacity * t}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="inline-grid">
|
||||||
|
{#key mode.current}
|
||||||
|
<div
|
||||||
|
in:spin={{ clockWise: true }}
|
||||||
|
out:spin={{ clockWise: false }}
|
||||||
|
class="col-start-1 row-start-1">
|
||||||
|
{#if mode.current === 'dark'}
|
||||||
|
<MoonIcon />
|
||||||
|
{:else}
|
||||||
|
<SunIcon />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
|
||||||
|
import Privacy from '$/components/Privacy.svelte';
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import { Separator } from '$/components/ui/separator';
|
||||||
|
import { TID } from '$/constants';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { version } from 'mermaid/package.json';
|
||||||
|
import { mode, setMode } from 'mode-watcher';
|
||||||
|
import ThemeIcon from './ThemeIcon.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FloatingToolbar>
|
||||||
|
<span class="text-sm font-semibold opacity-60">v{version}</span>
|
||||||
|
{#if !env.hidePrivacyPolicy}
|
||||||
|
<Button variant="ghost" size="icon" title="Privacy & Security">
|
||||||
|
<Privacy />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-testid={TID.themeToggleButton}
|
||||||
|
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme"
|
||||||
|
class="[&_svg]:size-5"
|
||||||
|
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
|
||||||
|
<ThemeIcon />
|
||||||
|
</Button>
|
||||||
|
</FloatingToolbar>
|
||||||
+76
-114
@@ -1,112 +1,93 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { State, ValidatedState } from '$lib/types';
|
import type { State, ValidatedState } from '$/types';
|
||||||
import { recordRenderTime, shouldRefreshView } from '$lib/util/autoSync';
|
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
|
||||||
import { render as renderDiagram } from '$lib/util/mermaid';
|
import { render as renderDiagram } from '$/util/mermaid';
|
||||||
import { inputStateStore, stateStore, updateCodeStore } from '$lib/util/state';
|
import { PanZoomState } from '$/util/panZoom';
|
||||||
import { logEvent, saveStatistics } from '$lib/util/stats';
|
import { updateCodeStore, validatedState } from '$/util/state.svelte';
|
||||||
import { cmdKey } from '$lib/util/util';
|
import { saveStatistics } from '$/util/stats';
|
||||||
|
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
||||||
|
import uniqueID from 'lodash-es/uniqueId';
|
||||||
import type { MermaidConfig } from 'mermaid';
|
import type { MermaidConfig } from 'mermaid';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import panzoom from 'svg-pan-zoom';
|
|
||||||
import { Svg2Roughjs } from 'svg2roughjs';
|
import { Svg2Roughjs } from 'svg2roughjs';
|
||||||
|
|
||||||
|
let {
|
||||||
|
panZoomState = new PanZoomState(),
|
||||||
|
shouldShowGrid = true
|
||||||
|
}: { panZoomState?: PanZoomState; shouldShowGrid?: boolean } = $props();
|
||||||
let code = '';
|
let code = '';
|
||||||
let config = '';
|
let config = '';
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement | undefined = $state();
|
||||||
let rough: boolean;
|
let rough: boolean;
|
||||||
let view: HTMLDivElement;
|
let view: HTMLDivElement | undefined = $state();
|
||||||
let error = false;
|
let error = $state(false);
|
||||||
let errorLines: string[] = [];
|
let panZoom = true;
|
||||||
let outOfSync = false;
|
|
||||||
let hide = false;
|
|
||||||
let manualUpdate = true;
|
let manualUpdate = true;
|
||||||
let panZoomEnabled = $stateStore.panZoom;
|
let waitForFontAwesomeToLoad: FontAwesome['waitForFontAwesomeToLoad'] | undefined = $state();
|
||||||
let pzoom: typeof panzoom | undefined;
|
|
||||||
|
|
||||||
const handlePanZoomChange = () => {
|
// Set up panZoom state observer to update the store when pan/zoom changes
|
||||||
if (!pzoom) {
|
const setupPanZoomObserver = () => {
|
||||||
return;
|
panZoomState.onPanZoomChange = (pan, zoom) => {
|
||||||
}
|
|
||||||
const pan = pzoom.getPan();
|
|
||||||
const zoom = pzoom.getZoom();
|
|
||||||
updateCodeStore({ pan, zoom });
|
updateCodeStore({ pan, zoom });
|
||||||
logEvent('panZoom');
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePanZoom = (state: State) => {
|
const handlePanZoom = (state: State, graphDiv: SVGSVGElement) => {
|
||||||
if (!state.panZoom) {
|
try {
|
||||||
return;
|
panZoomState.updateElement(graphDiv, state);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PanZoom error:', error);
|
||||||
}
|
}
|
||||||
hide = true;
|
|
||||||
pzoom?.destroy();
|
|
||||||
pzoom = undefined;
|
|
||||||
void Promise.resolve().then(() => {
|
|
||||||
const graphDiv = document.querySelector<HTMLElement>('#graph-div');
|
|
||||||
if (!graphDiv) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pzoom = panzoom(graphDiv, {
|
|
||||||
onPan: handlePanZoomChange,
|
|
||||||
onZoom: handlePanZoomChange,
|
|
||||||
controlIconsEnabled: true,
|
|
||||||
fit: true,
|
|
||||||
center: true
|
|
||||||
});
|
|
||||||
const { pan, zoom } = state;
|
|
||||||
if (pan !== undefined && zoom !== undefined && Number.isFinite(zoom)) {
|
|
||||||
pzoom.zoom(zoom);
|
|
||||||
pzoom.pan(pan);
|
|
||||||
}
|
|
||||||
hide = false;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStateChange = async (state: ValidatedState) => {
|
const handleStateChange = async (state: ValidatedState) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
if (state.error !== undefined) {
|
if (state.error !== undefined) {
|
||||||
error = true;
|
error = true;
|
||||||
errorLines = state.error.toString().split('\n');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error = false;
|
error = false;
|
||||||
|
let diagramType: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (container && state && (state.updateDiagram || state.autoSync)) {
|
if (container) {
|
||||||
if (!state.autoSync) {
|
|
||||||
$inputStateStore.updateDiagram = false;
|
|
||||||
}
|
|
||||||
outOfSync = false;
|
|
||||||
manualUpdate = true;
|
manualUpdate = true;
|
||||||
// Do not render if there is no change in Code/Config/PanZoom
|
// Do not render if there is no change in Code/Config/PanZoom
|
||||||
if (
|
if (
|
||||||
code === state.code &&
|
code === state.code &&
|
||||||
config === state.mermaid &&
|
config === state.mermaid &&
|
||||||
panZoomEnabled === state.panZoom &&
|
rough === state.rough &&
|
||||||
rough === state.rough
|
panZoom === state.panZoom
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldRefreshView()) {
|
if (!shouldRefreshView()) {
|
||||||
outOfSync = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
code = state.code;
|
code = state.code;
|
||||||
config = state.mermaid;
|
config = state.mermaid;
|
||||||
panZoomEnabled = state.panZoom;
|
|
||||||
rough = state.rough;
|
rough = state.rough;
|
||||||
const scroll = view.parentElement?.scrollTop;
|
panZoom = state.panZoom ?? true;
|
||||||
delete container.dataset.processed;
|
|
||||||
const { svg, bindFunctions } = await renderDiagram(
|
|
||||||
Object.assign({}, JSON.parse(state.mermaid)) as MermaidConfig,
|
|
||||||
code,
|
|
||||||
'graph-div'
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (mayContainFontAwesome(code)) {
|
||||||
|
await waitForFontAwesomeToLoad?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroll = view?.parentElement?.scrollTop;
|
||||||
|
delete container.dataset.processed;
|
||||||
|
const viewID = uniqueID('graph-');
|
||||||
|
const {
|
||||||
|
svg,
|
||||||
|
bindFunctions,
|
||||||
|
diagramType: detectedDiagramType
|
||||||
|
} = await renderDiagram(JSON.parse(state.mermaid) as MermaidConfig, code, viewID);
|
||||||
|
diagramType = detectedDiagramType;
|
||||||
if (svg.length > 0) {
|
if (svg.length > 0) {
|
||||||
handlePanZoom(state);
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||||
container.innerHTML = svg;
|
container.innerHTML = svg;
|
||||||
const graphDiv = document.querySelector<SVGSVGElement>('#graph-div');
|
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`);
|
||||||
if (!graphDiv) {
|
if (!graphDiv) {
|
||||||
throw new Error('graph-div not found');
|
throw new Error('graph-div not found');
|
||||||
}
|
}
|
||||||
@@ -115,16 +96,18 @@
|
|||||||
svg2roughjs.svg = graphDiv;
|
svg2roughjs.svg = graphDiv;
|
||||||
await svg2roughjs.sketch();
|
await svg2roughjs.sketch();
|
||||||
graphDiv.remove();
|
graphDiv.remove();
|
||||||
const sketch = document.querySelector<HTMLElement>('#container > svg');
|
const sketch = document.querySelector<SVGSVGElement>('#container > svg');
|
||||||
if (!sketch) {
|
if (!sketch) {
|
||||||
throw new Error('sketch not found');
|
throw new Error('sketch not found');
|
||||||
}
|
}
|
||||||
const height = sketch.getAttribute('height');
|
const height = sketch.getAttribute('height');
|
||||||
const width = sketch.getAttribute('width');
|
const width = sketch.getAttribute('width');
|
||||||
|
sketch.setAttribute('id', 'graph-div');
|
||||||
sketch.setAttribute('height', '100%');
|
sketch.setAttribute('height', '100%');
|
||||||
sketch.setAttribute('width', '100%');
|
sketch.setAttribute('width', '100%');
|
||||||
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
sketch.style.maxWidth = '100%';
|
sketch.style.maxWidth = '100%';
|
||||||
|
graphDiv = sketch;
|
||||||
} else {
|
} else {
|
||||||
graphDiv.setAttribute('height', '100%');
|
graphDiv.setAttribute('height', '100%');
|
||||||
graphDiv.style.maxWidth = '100%';
|
graphDiv.style.maxWidth = '100%';
|
||||||
@@ -132,79 +115,58 @@
|
|||||||
bindFunctions(graphDiv);
|
bindFunctions(graphDiv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.panZoom) {
|
||||||
|
handlePanZoom(state, graphDiv);
|
||||||
}
|
}
|
||||||
if (view.parentElement && scroll) {
|
}
|
||||||
|
if (view?.parentElement && scroll) {
|
||||||
view.parentElement.scrollTop = scroll;
|
view.parentElement.scrollTop = scroll;
|
||||||
}
|
}
|
||||||
error = false;
|
error = false;
|
||||||
} else if (manualUpdate) {
|
} else if (manualUpdate) {
|
||||||
manualUpdate = false;
|
manualUpdate = false;
|
||||||
} else if (code !== state.code || config !== state.mermaid) {
|
|
||||||
outOfSync = true;
|
|
||||||
}
|
}
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
console.error('view fail', error_);
|
console.error('view fail', error_);
|
||||||
error = true;
|
error = true;
|
||||||
}
|
}
|
||||||
const renderTime = Date.now() - startTime;
|
const renderTime = Date.now() - startTime;
|
||||||
saveStatistics({ code, renderTime, isRough: state.rough });
|
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
|
||||||
recordRenderTime(renderTime, () => {
|
recordRenderTime(renderTime, () => {
|
||||||
$inputStateStore.updateDiagram = true;
|
updateCodeStore({ updateDiagram: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
stateStore.subscribe((state) => {
|
setupPanZoomObserver();
|
||||||
void handleStateChange(state);
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if ($stateStore.panZoom && pzoom) {
|
|
||||||
pzoom.resize();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Queue state changes to avoid race condition
|
||||||
|
let pendingStateChange = Promise.resolve();
|
||||||
|
$effect(() => {
|
||||||
|
const state = validatedState.current;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if (error && $stateStore.error instanceof Error) || outOfSync}
|
<FontAwesome bind:waitForFontAwesomeToLoad />
|
||||||
<div
|
|
||||||
class="absolute z-10 w-full p-2 font-mono {error
|
|
||||||
? 'text-red-600'
|
|
||||||
: 'text-yellow-600'} bg-base-100 bg-opacity-80 text-left"
|
|
||||||
id="errorContainer">
|
|
||||||
{#if error}
|
|
||||||
{#each errorLines as line}
|
|
||||||
{line}<br />
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
Diagram out of sync. <br />
|
|
||||||
{#if $stateStore.autoSync}
|
|
||||||
It will be updated automatically.
|
|
||||||
{:else}
|
|
||||||
Press <i class="fas fa-sync" /> (Sync button) or <kbd>{cmdKey} + Enter</kbd> to sync.
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div id="view" bind:this={view} class="h-full p-2" class:error class:outOfSync>
|
<div
|
||||||
<div id="container" bind:this={container} class="h-full overflow-auto" class:hide />
|
id="view"
|
||||||
|
bind:this={view}
|
||||||
|
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}>
|
||||||
|
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#view {
|
.grid-bg-light {
|
||||||
flex: 1;
|
background-size: 30px 30px;
|
||||||
|
background-image: radial-gradient(circle, #e4e4e48c 2px, #0000 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#container {
|
.grid-bg-dark {
|
||||||
transition: visibility 0.3s;
|
background-size: 30px 30px;
|
||||||
}
|
background-image: radial-gradient(circle, #46464646 2px, #0000 2px);
|
||||||
|
|
||||||
.error,
|
|
||||||
.outOfSync {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
|
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||||
|
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
|
||||||
|
import { Button } from '$/components/ui/button';
|
||||||
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
|
import { dismissEditorChooser } from '$/util/migration/domainMigration';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import AtlassianIcon from '~icons/logos/atlassian';
|
||||||
|
import AmazonIcon from '~icons/logos/aws';
|
||||||
|
import GoogleIcon from '~icons/logos/google';
|
||||||
|
import MicrosoftIcon from '~icons/logos/microsoft';
|
||||||
|
import { createEditorChooserActions } from './editorChooserActions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable() }: Props = $props();
|
||||||
|
// Tracks whether the current close was triggered by an explicit action
|
||||||
|
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
|
||||||
|
let handled = false;
|
||||||
|
const actions = createEditorChooserActions(() => {
|
||||||
|
handled = true;
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
|
||||||
|
{
|
||||||
|
title: 'Visual drag-and-drop editor',
|
||||||
|
description: 'Edit diagrams without writing code'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Unlimited diagram storage',
|
||||||
|
description: 'Save and organize all your diagrams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Team collaboration',
|
||||||
|
description: 'Share, comment, and edit together in real-time'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const trustedLogos: { name: string; icon: Component }[] = [
|
||||||
|
{ name: 'Google', icon: GoogleIcon },
|
||||||
|
{ name: 'Microsoft', icon: MicrosoftIcon },
|
||||||
|
{ name: 'Atlassian', icon: AtlassianIcon },
|
||||||
|
{ name: 'Amazon', icon: AmazonIcon }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (v) return;
|
||||||
|
if (!handled) actions.log('dismissed');
|
||||||
|
handled = false;
|
||||||
|
dismissEditorChooser();
|
||||||
|
}}>
|
||||||
|
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
|
||||||
|
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
|
||||||
|
<MermaidChartIcon class="size-10" />
|
||||||
|
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-xs font-light text-muted-foreground">
|
||||||
|
Free forever, with Plus features free for 7 days.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<ul class="mt-2 flex flex-col gap-3">
|
||||||
|
{#each features as feature (feature.title)}
|
||||||
|
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||||
|
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<p class="text-xs">{feature.title}</p>
|
||||||
|
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
<McWrapper labelPrefix="Opens ">
|
||||||
|
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
|
||||||
|
</McWrapper>
|
||||||
|
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
|
||||||
|
Stay on mermaid.live
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col items-start gap-4">
|
||||||
|
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
|
||||||
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
|
{#each trustedLogos as logo (logo.name)}
|
||||||
|
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrivacyPolicyLink />
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts"></script>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a
|
||||||
|
href="https://mermaid.ai/privacy-policy"
|
||||||
|
target="_blank"
|
||||||
|
class="text-sm text-foreground underline hover:text-accent">
|
||||||
|
mermaid.ai Privacy Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { logEvent, logMermaidChartClick } from '$/util/stats';
|
||||||
|
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
|
||||||
|
|
||||||
|
const utmMedium = 'editorSelection';
|
||||||
|
const utmCampaign = 'live_2026';
|
||||||
|
|
||||||
|
export interface EditorChooserActions {
|
||||||
|
log: (buttonClick: string) => void;
|
||||||
|
startTrial: (buttonClick?: string) => void;
|
||||||
|
dismiss: (buttonClick: string) => void;
|
||||||
|
openMermaidAiLive: (buttonClick: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
|
||||||
|
const log = (buttonClick: string) => {
|
||||||
|
logEvent('chooseEditor', { buttonClick });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTrial = (buttonClick = 'startTrial') => {
|
||||||
|
log(buttonClick);
|
||||||
|
logMermaidChartClick('editorPicker');
|
||||||
|
close();
|
||||||
|
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = (buttonClick: string) => {
|
||||||
|
log(buttonClick);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMermaidAiLive = (buttonClick: string) => {
|
||||||
|
log(buttonClick);
|
||||||
|
close();
|
||||||
|
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { log, startTrial, dismiss, openMermaidAiLive };
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: 'focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-6 [&_svg]:shrink-0',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||||
|
outline:
|
||||||
|
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
|
||||||
|
accent: 'bg-accent text-accent-foreground hover:bg-accent/80 shadow-sm',
|
||||||
|
ghost: 'hover:bg-primary hover:text-primary-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'size-8'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
default as Button,
|
||||||
|
buttonVariants,
|
||||||
|
default as Root,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonProps as Props
|
||||||
|
} from './button.svelte';
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import * as Dialog from './index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:text-muted-foreground">
|
||||||
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...restProps} />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
{...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps} />
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
class={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||||
|
{...restProps} />
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import Content from './dialog-content.svelte';
|
||||||
|
import Description from './dialog-description.svelte';
|
||||||
|
import Footer from './dialog-footer.svelte';
|
||||||
|
import Header from './dialog-header.svelte';
|
||||||
|
import Overlay from './dialog-overlay.svelte';
|
||||||
|
import Title from './dialog-title.svelte';
|
||||||
|
|
||||||
|
const Root: typeof DialogPrimitive.Root = DialogPrimitive.Root;
|
||||||
|
const Trigger: typeof DialogPrimitive.Trigger = DialogPrimitive.Trigger;
|
||||||
|
const Close: typeof DialogPrimitive.Close = DialogPrimitive.Close;
|
||||||
|
const Portal: typeof DialogPrimitive.Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Close,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Close as DialogClose,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Overlay,
|
||||||
|
Portal,
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Trigger
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from './input.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InputType } from '$/types';
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, 'type'> &
|
||||||
|
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === 'file'}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps} />
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps} />
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
import Content from './popover-content.svelte';
|
||||||
|
const Root = PopoverPrimitive.Root;
|
||||||
|
const Trigger = PopoverPrimitive.Trigger;
|
||||||
|
const Close = PopoverPrimitive.Close;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: PopoverPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{align}
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps} />
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Pane } from 'paneforge';
|
||||||
|
import Handle from './resizable-handle.svelte';
|
||||||
|
import PaneGroup from './resizable-pane-group.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
PaneGroup,
|
||||||
|
Pane,
|
||||||
|
Handle,
|
||||||
|
//
|
||||||
|
PaneGroup as ResizablePaneGroup,
|
||||||
|
Pane as ResizablePane,
|
||||||
|
Handle as ResizableHandle
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user