mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 15:56:29 +01:00
Compare commits
2038 Commits
feat/bette
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff06376a30 | ||
|
|
ee2edc92e7 | ||
|
|
332216f5f5 | ||
|
|
4d4cd7d130 | ||
|
|
b3cc51ce63 | ||
|
|
05645d93ef | ||
|
|
a2d09efca4 | ||
|
|
1212f9a9e9 | ||
|
|
75f7986e36 | ||
|
|
2447a6fc8d | ||
|
|
38a4a3e7b6 | ||
|
|
28240d549d | ||
|
|
5da46a1678 | ||
|
|
e592a37799 | ||
|
|
15d00b61dd | ||
|
|
5d0b6f9fad | ||
|
|
2a40ffd164 | ||
|
|
8c687de3c6 | ||
|
|
27415b4e16 | ||
|
|
ce0b39765e | ||
|
|
fafc4af237 | ||
|
|
76a283ed77 | ||
|
|
99500bca8f | ||
|
|
a982fc326f | ||
|
|
b16309d01a | ||
|
|
1bfc3d450f | ||
|
|
37b63d4ea9 | ||
|
|
e7315e7d35 | ||
|
|
718dffa672 | ||
|
|
c3dd9865e7 | ||
|
|
1702ec5644 | ||
|
|
dfddf044cf | ||
|
|
24a632056a | ||
|
|
dc7f4a6cf3 | ||
|
|
cd100f37fe | ||
|
|
984e8bbba0 | ||
|
|
fcc22cc212 | ||
|
|
4101acc2e3 | ||
|
|
f30bdd54b1 | ||
|
|
62bb8ac89a | ||
|
|
33dfcb1c6e | ||
|
|
5969815ed1 | ||
|
|
2b2125c702 | ||
|
|
85a4557bb0 | ||
|
|
0d9e4a1aa2 | ||
|
|
4a01181110 | ||
|
|
89dfc480f3 | ||
|
|
c178fc2957 | ||
|
|
9d8c62caaf | ||
|
|
279f014c42 | ||
|
|
826e9c7114 | ||
|
|
384c8649b4 | ||
|
|
ecc8bc3866 | ||
|
|
bb4d723f18 | ||
|
|
e250510ab1 | ||
|
|
dc630f927f | ||
|
|
e47cb13b89 | ||
|
|
838ae315e3 | ||
|
|
58afa86a2b | ||
|
|
5b90ece12f | ||
|
|
afb2072b97 | ||
|
|
8e0ca56b85 | ||
|
|
8e4cf38840 | ||
|
|
cb872d3638 | ||
|
|
781be26833 | ||
|
|
0ad5f3493d | ||
|
|
15bb3acb31 | ||
|
|
a43ddf3f9f | ||
|
|
8417bfebb0 | ||
|
|
28e8ea2da9 | ||
|
|
393264b4a1 | ||
|
|
9306a28c87 | ||
|
|
77fef38009 | ||
|
|
c14ea42978 | ||
|
|
341cd62b13 | ||
|
|
9e6cfe7c1e | ||
|
|
9695dd404b | ||
|
|
5e6be01251 | ||
|
|
f367a1c776 | ||
|
|
bce987c67a | ||
|
|
e7a9f9c566 | ||
|
|
33150e3a98 | ||
|
|
e1aead098e | ||
|
|
9f9a276a51 | ||
|
|
873df6da6c | ||
|
|
2e353afb98 | ||
|
|
d497688d9a | ||
|
|
2cf3a04482 | ||
|
|
e50fd6f540 | ||
|
|
5096163ae3 | ||
|
|
0d6640ae14 | ||
|
|
52ac93e99c | ||
|
|
674b0a8215 | ||
|
|
ec56b297dc | ||
|
|
a477cc22e6 | ||
|
|
119278b5f5 | ||
|
|
5414fbeacb | ||
|
|
a4b01bba9b | ||
|
|
bab536751a | ||
|
|
7657e17373 | ||
|
|
30f530abdb | ||
|
|
1d373bc7d5 | ||
|
|
9d3c5d04b9 | ||
|
|
ba91fbbe6b | ||
|
|
f6898779bb | ||
|
|
dbb90bdd2b | ||
|
|
f442c56ed6 | ||
|
|
e971a9cb03 | ||
|
|
b450a4faa0 | ||
|
|
9a2440942b | ||
|
|
c3151f9afa | ||
|
|
f277612444 | ||
|
|
1b92ad2f53 | ||
|
|
f96abe0e45 | ||
|
|
142a276cc4 | ||
|
|
a52b0a45fe | ||
|
|
6df40ec80a | ||
|
|
713340a9ba | ||
|
|
ee8b41c81b | ||
|
|
dd477258a9 | ||
|
|
7c30e2b4f6 | ||
|
|
37a3c00214 | ||
|
|
d30cdadb2d | ||
|
|
58f0d01944 | ||
|
|
d4791944b0 | ||
|
|
92a052674f | ||
|
|
d49ce7c289 | ||
|
|
5f38d52f20 | ||
|
|
6286745684 | ||
|
|
4f574f8aa4 | ||
|
|
a3e27248ad | ||
|
|
e48569245d | ||
|
|
473f7a83e6 | ||
|
|
1c622fa848 | ||
|
|
409e650506 | ||
|
|
d461f5474e | ||
|
|
36e731cc2c | ||
|
|
b77fbcb7ad | ||
|
|
7b7dc346ca | ||
|
|
b4f8a02ba6 | ||
|
|
9d6a5d1bb5 | ||
|
|
fa747c5c4b | ||
|
|
bc93f40cdb | ||
|
|
a9975798d7 | ||
|
|
4ea4404aba | ||
|
|
361848b518 | ||
|
|
8c46103f63 | ||
|
|
edcdecb720 | ||
|
|
e1ef02058d | ||
|
|
d9746df16b | ||
|
|
b4c20d9683 | ||
|
|
59955b7414 | ||
|
|
95b1c82ccb | ||
|
|
7cfebbabeb | ||
|
|
f412874c73 | ||
|
|
873c4c6636 | ||
|
|
efcd54be50 | ||
|
|
ae0bb78b1c | ||
|
|
29fa335a27 | ||
|
|
3a84a78cd1 | ||
|
|
df6bb7e6bf | ||
|
|
dfcaebc613 | ||
|
|
1ece35536b | ||
|
|
1a90548622 | ||
|
|
cc7e5bdb80 | ||
|
|
5c73b21ff7 | ||
|
|
e9078107ae | ||
|
|
6c24b18bc1 | ||
|
|
be7ff73142 | ||
|
|
63ac45d369 | ||
|
|
f164a4b786 | ||
|
|
806d601115 | ||
|
|
6b3cf49398 | ||
|
|
4a72f2c6a7 | ||
|
|
446bdd6a5e | ||
|
|
cb26fac2ea | ||
|
|
3a45440c74 | ||
|
|
e115d6e275 | ||
|
|
fc614ccf83 | ||
|
|
17c9db7698 | ||
|
|
032819e812 | ||
|
|
9d7d415756 | ||
|
|
e9d432b4bf | ||
|
|
be35584f9a | ||
|
|
80be4cc6b8 | ||
|
|
c74ba44b91 | ||
|
|
28a79baa01 | ||
|
|
5279601105 | ||
|
|
c2639951a5 | ||
|
|
6bfab1387d | ||
|
|
4212d208fc | ||
|
|
9e5bded4cf | ||
|
|
ce0763f03d | ||
|
|
e860b7aa32 | ||
|
|
0be9310450 | ||
|
|
d870a260e1 | ||
|
|
2de545be1c | ||
|
|
bf04e5a15b | ||
|
|
46d2d7e160 | ||
|
|
1775b22c7a | ||
|
|
ae022b6389 | ||
|
|
d4155102c5 | ||
|
|
3b1a25230f | ||
|
|
d8639793e0 | ||
|
|
06fec88214 | ||
|
|
da93928976 | ||
|
|
2314443d19 | ||
|
|
4b34047324 | ||
|
|
bc1d4de13d | ||
|
|
6bae4c8075 | ||
|
|
84a89fd0ba | ||
|
|
1447fa6f14 | ||
|
|
bc4937f9d2 | ||
|
|
43a7b828d9 | ||
|
|
d611591e1a | ||
|
|
f7a565ec73 | ||
|
|
6b35e909ab | ||
|
|
53b9ce0f3d | ||
|
|
5e1cd7d6ac | ||
|
|
acb98061ce | ||
|
|
979ef6287f | ||
|
|
58a883797d | ||
|
|
f718e87673 | ||
|
|
4c6a742af7 | ||
|
|
848dc51a7a | ||
|
|
44541b66c4 | ||
|
|
3694018441 | ||
|
|
3874e54d76 | ||
|
|
10de141c00 | ||
|
|
806ba320a8 | ||
|
|
09ef24d27d | ||
|
|
236f3cada7 | ||
|
|
c2afef4832 | ||
|
|
b9fa7d70bb | ||
|
|
9ac31f2667 | ||
|
|
639d1befef | ||
|
|
b99c8d5cc1 | ||
|
|
0770398010 | ||
|
|
c3d24451b7 | ||
|
|
4db1a3bdec | ||
|
|
2a0410f597 | ||
|
|
7e79d907be | ||
|
|
c0ea441c59 | ||
|
|
290d134d88 | ||
|
|
517bfd2c9a | ||
|
|
31990a9992 | ||
|
|
151a2c284d | ||
|
|
614a8f177c | ||
|
|
8b5e53e579 | ||
|
|
1ad8b1bf85 | ||
|
|
a393584a2a | ||
|
|
804fc72ed8 | ||
|
|
3f8f05368c | ||
|
|
5a56ba2fd5 | ||
|
|
de7c1329f8 | ||
|
|
6fc5aa0090 | ||
|
|
2eaeccda05 | ||
|
|
56f970ab08 | ||
|
|
d1cb9e4a3f | ||
|
|
992adcab65 | ||
|
|
d3b3a83477 | ||
|
|
98ad371d01 | ||
|
|
3cdae245e1 | ||
|
|
887c78d893 | ||
|
|
e208497d71 | ||
|
|
dd744b4e0d | ||
|
|
647cbc7e7a | ||
|
|
cc93102859 | ||
|
|
5b673e753b | ||
|
|
bd2eb6fdbb | ||
|
|
c25f783980 | ||
|
|
9b37708f0c | ||
|
|
7386bb35e5 | ||
|
|
9274522877 | ||
|
|
1b444686af | ||
|
|
b3dfdacdc3 | ||
|
|
159fab41ce | ||
|
|
093f48f76a | ||
|
|
6998a3593e | ||
|
|
c3250cfd72 | ||
|
|
e52eb9bcb0 | ||
|
|
5ac2892e34 | ||
|
|
a15aab395a | ||
|
|
f991276152 | ||
|
|
1f71ceb611 | ||
|
|
92e14159b9 | ||
|
|
b8419604e5 | ||
|
|
096fd82e64 | ||
|
|
cbaae52a7e | ||
|
|
5905299331 | ||
|
|
2df0763141 | ||
|
|
fb7453f7b0 | ||
|
|
974d20b0ba | ||
|
|
c2f6d9aa07 | ||
|
|
6194386464 | ||
|
|
329ecd6894 | ||
|
|
150f470aee | ||
|
|
83b843f047 | ||
|
|
24043611c3 | ||
|
|
2c99ba64bc | ||
|
|
ffe30bed75 | ||
|
|
b6088f488f | ||
|
|
e04165a184 | ||
|
|
d7aa95ce8e | ||
|
|
28214ec9fb | ||
|
|
28952a5253 | ||
|
|
d66505e5bc | ||
|
|
c4354032b5 | ||
|
|
d23550d3ef | ||
|
|
894ec1e3c1 | ||
|
|
bdb03f8d51 | ||
|
|
61ea27c8f4 | ||
|
|
ac45617d8f | ||
|
|
35853ff988 | ||
|
|
80009f99e8 | ||
|
|
0b1d001c20 | ||
|
|
cb63e88cdc | ||
|
|
a5c7f4221b | ||
|
|
f61010a65e | ||
|
|
b99f5b2cbe | ||
|
|
9919d0cbfa | ||
|
|
fe8099d8d1 | ||
|
|
0da336c8e1 | ||
|
|
37f5d19739 | ||
|
|
2ac0d84cee | ||
|
|
e639961b68 | ||
|
|
adf29b4e6e | ||
|
|
b00cd032a3 | ||
|
|
60e8f46777 | ||
|
|
ef2860770f | ||
|
|
aa562e9c26 | ||
|
|
866ccc1696 | ||
|
|
dbe241dee7 | ||
|
|
bd32a08e11 | ||
|
|
8416dab870 | ||
|
|
6fda669307 | ||
|
|
c21a9223f5 | ||
|
|
4e4e65b462 | ||
|
|
5e07231d78 | ||
|
|
d71d1ce8b4 | ||
|
|
892e84deaa | ||
|
|
7a73af0299 | ||
|
|
65dae511e5 | ||
|
|
55c70b404c | ||
|
|
8117586548 | ||
|
|
3ce9c7ba3d | ||
|
|
ab162efab8 | ||
|
|
babfc3cfb9 | ||
|
|
3d780d7d02 | ||
|
|
5b81aff8be | ||
|
|
86b14a5763 | ||
|
|
14a2794d15 | ||
|
|
eef68aca0f | ||
|
|
f6f7445528 | ||
|
|
f7c0184a6b | ||
|
|
65bc599a16 | ||
|
|
b445bef74c | ||
|
|
93d7ba032d | ||
|
|
334e2c3949 | ||
|
|
a11797fe6e | ||
|
|
3cf0ec5740 | ||
|
|
40f578f43f | ||
|
|
428abb4591 | ||
|
|
4954fa89b5 | ||
|
|
10f7837a7f | ||
|
|
fca310cc31 | ||
|
|
cf58b511df | ||
|
|
9da1f52a71 | ||
|
|
5c8e674ddb | ||
|
|
f027b25bc2 | ||
|
|
cfe71a3426 | ||
|
|
70afb636ca | ||
|
|
818efe7fb0 | ||
|
|
3c2263db86 | ||
|
|
7c30bc9c72 | ||
|
|
ec8d719d41 | ||
|
|
5f884c4440 | ||
|
|
e6c806d462 | ||
|
|
4afceeca79 | ||
|
|
7990c60ce7 | ||
|
|
74c248bce2 | ||
|
|
2df646f80c | ||
|
|
768260782a | ||
|
|
4eff105af5 | ||
|
|
68ef6ea142 | ||
|
|
772d4ac5a1 | ||
|
|
b3e1a79d40 | ||
|
|
d7afa8526d | ||
|
|
b4a2a6c12b | ||
|
|
32c15f5e03 | ||
|
|
b5a491820c | ||
|
|
8a477c87e0 | ||
|
|
8bcae8cdb8 | ||
|
|
ea87161a91 | ||
|
|
f2bb6cb848 | ||
|
|
fecb677552 | ||
|
|
3ba9b56833 | ||
|
|
a329e7d72a | ||
|
|
2568f6bb53 | ||
|
|
23936596fa | ||
|
|
398db56fe7 | ||
|
|
423ef14ca6 | ||
|
|
9921d3e0a7 | ||
|
|
ac22fd8d60 | ||
|
|
ff065964e9 | ||
|
|
951dda50ac | ||
|
|
01bd2f1815 | ||
|
|
570f8fd155 | ||
|
|
5d348e3ad6 | ||
|
|
9573346d55 | ||
|
|
e4570a1bb0 | ||
|
|
b884aba244 | ||
|
|
9c73908560 | ||
|
|
b399d292a9 | ||
|
|
a557d9770f | ||
|
|
a11ebfeb42 | ||
|
|
4695c3726d | ||
|
|
324f79ceb9 | ||
|
|
fc5e459895 | ||
|
|
bdaba67859 | ||
|
|
2b01e2fdaf | ||
|
|
9967da6ea1 | ||
|
|
a56a00ba2f | ||
|
|
82df26031f | ||
|
|
b4af8e7339 | ||
|
|
e6180f427b | ||
|
|
9a95ec170d | ||
|
|
7be0507db5 | ||
|
|
1d324ab3b0 | ||
|
|
9e00ed7e14 | ||
|
|
02a6652b44 | ||
|
|
e19f7b286a | ||
|
|
50301a97f3 | ||
|
|
408e31079c | ||
|
|
bf81e159ca | ||
|
|
b1f89296ff | ||
|
|
1948302a64 | ||
|
|
187585b32f | ||
|
|
d351fd506a | ||
|
|
1cf29c985e | ||
|
|
04374540ad | ||
|
|
ce8f3a4f8f | ||
|
|
acb21b992d | ||
|
|
15f344fe4a | ||
|
|
120324c3f0 | ||
|
|
59ce8b912d | ||
|
|
619888847b | ||
|
|
06b782e91d | ||
|
|
1d7e0a193a | ||
|
|
2d815852e4 | ||
|
|
735a7104f1 | ||
|
|
e3e4772aab | ||
|
|
8bb65b94d0 | ||
|
|
b9edae4fc9 | ||
|
|
27aae18345 | ||
|
|
47db63d909 | ||
|
|
8ebeead32c | ||
|
|
09d43e710f | ||
|
|
7240f64a49 | ||
|
|
ab868d76db | ||
|
|
acdaf6a636 | ||
|
|
6dccef1689 | ||
|
|
f7ec726b15 | ||
|
|
781570f950 | ||
|
|
7774d41457 | ||
|
|
27e6d1b00b | ||
|
|
73ea0cce32 | ||
|
|
a1741b8634 | ||
|
|
a0f1a63fb6 | ||
|
|
7c13373f16 | ||
|
|
239b7b810d | ||
|
|
29c8bcaf6e | ||
|
|
2b3ae94f8d | ||
|
|
e753924c4b | ||
|
|
8080d3b8a7 | ||
|
|
1f4dd04ef0 | ||
|
|
348432bd5b | ||
|
|
d2962b060e | ||
|
|
fae66e555e | ||
|
|
aeb9bfc1fd | ||
|
|
5a15024e59 | ||
|
|
23c2acaab7 | ||
|
|
4cc55b02ab | ||
|
|
71ce9c459e | ||
|
|
97b5ea0798 | ||
|
|
5fd0f79d44 | ||
|
|
2a090c7014 | ||
|
|
126030f17e | ||
|
|
f22fd1d454 | ||
|
|
8d4c656a6f | ||
|
|
3c5a053a2c | ||
|
|
664b7e45e7 | ||
|
|
5c618abc79 | ||
|
|
220cf8aedd | ||
|
|
78f16ddc12 | ||
|
|
0048e95e0c | ||
|
|
13e9fcbfba | ||
|
|
5249911ddb | ||
|
|
59fe1299b2 | ||
|
|
1c9f1ba82c | ||
|
|
311f4aded8 | ||
|
|
ed8df51216 | ||
|
|
5e4d403556 | ||
|
|
f3a9c718ad | ||
|
|
f3733eb341 | ||
|
|
3b06845a71 | ||
|
|
94e20c44e5 | ||
|
|
1638fd8590 | ||
|
|
effe0a4f51 | ||
|
|
bb3ac277f4 | ||
|
|
68aacfea6f | ||
|
|
e0056a457e | ||
|
|
4d6c2fd8cb | ||
|
|
f63b8cef2d | ||
|
|
f19da292c1 | ||
|
|
36003b76e9 | ||
|
|
1c627dec05 | ||
|
|
2dcb67b099 | ||
|
|
de8a090410 | ||
|
|
fc09a41ba0 | ||
|
|
12f461d0ea | ||
|
|
1256338ab5 | ||
|
|
43c761328d | ||
|
|
6f565afd44 | ||
|
|
5c27e96960 | ||
|
|
c0337befa7 | ||
|
|
3bda10caf0 | ||
|
|
a25e376f85 | ||
|
|
1b238a98de | ||
|
|
38659e501e | ||
|
|
113af940c1 | ||
|
|
089ca7fd29 | ||
|
|
529523dd4e | ||
|
|
89417f15dc | ||
|
|
51692aabd5 | ||
|
|
9cde4c26d9 | ||
|
|
c7bce91b67 | ||
|
|
91b6910a9c | ||
|
|
9fb37968f8 | ||
|
|
6cfc6509f6 | ||
|
|
fd054693d9 | ||
|
|
8b65de2442 | ||
|
|
25905ebff7 | ||
|
|
e88b59009a | ||
|
|
3aee1c8546 | ||
|
|
2dd554a8be | ||
|
|
f4fae04327 | ||
|
|
21032d1bb8 | ||
|
|
6745b887fb | ||
|
|
d0d166e426 | ||
|
|
754b95876e | ||
|
|
0bb10cf3ee | ||
|
|
d276cdf519 | ||
|
|
2768b76278 | ||
|
|
d244803501 | ||
|
|
158ca2acf2 | ||
|
|
3ef44febd8 | ||
|
|
5be41ee669 | ||
|
|
b887d4a7d2 | ||
|
|
be1de86a42 | ||
|
|
345d098e5f | ||
|
|
fae5421516 | ||
|
|
934f144bf9 | ||
|
|
5affb837a6 | ||
|
|
188319d2d9 | ||
|
|
fe762577b1 | ||
|
|
f30da3d13b | ||
|
|
053a84483c | ||
|
|
34338a795f | ||
|
|
012aceb7f2 | ||
|
|
a92604e92f | ||
|
|
9a9edf16c4 | ||
|
|
daba190e74 | ||
|
|
8877eded9b | ||
|
|
0b05f597dc | ||
|
|
b26803b627 | ||
|
|
17e87278eb | ||
|
|
79718c7e6e | ||
|
|
0917c25bce | ||
|
|
45c3f6d44a | ||
|
|
90337016e7 | ||
|
|
c0c1c8a9c2 | ||
|
|
42a082f11b | ||
|
|
891e6b9751 | ||
|
|
2be9d71659 | ||
|
|
3f562332c7 | ||
|
|
edd7e43b41 | ||
|
|
6ea1e31350 | ||
|
|
770648619e | ||
|
|
08c3e97a46 | ||
|
|
9a08b864ee | ||
|
|
039d6e6a4e | ||
|
|
36692a5ad7 | ||
|
|
67c7d7575d | ||
|
|
bb51eed0bc | ||
|
|
adce041b02 | ||
|
|
183d11ff72 | ||
|
|
a9f5b44fac | ||
|
|
c4560c2bc8 | ||
|
|
ba740eff9b | ||
|
|
9dcf46cbb3 | ||
|
|
7782b11186 | ||
|
|
e1b8f973d5 | ||
|
|
a51e475095 | ||
|
|
13685d2688 | ||
|
|
8bef36c6c7 | ||
|
|
207807e0c2 | ||
|
|
b5c82af464 | ||
|
|
3fa95d4fee | ||
|
|
ee43b21b0c | ||
|
|
29e091461f | ||
|
|
5b6a2b1f5d | ||
|
|
d657303f2f | ||
|
|
a4d541ae1c | ||
|
|
b38631b04b | ||
|
|
fe0f8ad83d | ||
|
|
46950cbceb | ||
|
|
9893de4642 | ||
|
|
b9055c6810 | ||
|
|
f068b335f5 | ||
|
|
7c750811cc | ||
|
|
2edce23a29 | ||
|
|
3efe628eb7 | ||
|
|
fdbb88ccd1 | ||
|
|
c44395887b | ||
|
|
1ae81abf0a | ||
|
|
74b89098c5 | ||
|
|
ae46798d1d | ||
|
|
b502e999de | ||
|
|
57004ab848 | ||
|
|
fbd47025d6 | ||
|
|
f87d270caa | ||
|
|
2ccaf5f97c | ||
|
|
641c6f4595 | ||
|
|
eb1039d9f7 | ||
|
|
349d946e6f | ||
|
|
170e271bb4 | ||
|
|
adca755598 | ||
|
|
f58cbc64bb | ||
|
|
8d5e8c7ea8 | ||
|
|
411d61d251 | ||
|
|
e7556f7dfa | ||
|
|
3e0f07aa48 | ||
|
|
e5a90662eb | ||
|
|
9886376738 | ||
|
|
73603f6593 | ||
|
|
88bc6739ca | ||
|
|
a4e8e62452 | ||
|
|
78e45d095b | ||
|
|
834c67aeff | ||
|
|
23b798e392 | ||
|
|
bd374bf617 | ||
|
|
87d8bcdde5 | ||
|
|
a840d91379 | ||
|
|
0fcff6639f | ||
|
|
f607c9793d | ||
|
|
06254442c9 | ||
|
|
c5725a5850 | ||
|
|
dbad13c4e2 | ||
|
|
a274da80b7 | ||
|
|
66c05619df | ||
|
|
67c99dea2d | ||
|
|
c77b7f8c74 | ||
|
|
cc51fbe77e | ||
|
|
2e510f9dbb | ||
|
|
e12df98d12 | ||
|
|
d8402755ee | ||
|
|
614b704702 | ||
|
|
fb6e87b0a5 | ||
|
|
80baa31221 | ||
|
|
e2f1f56e06 | ||
|
|
8ed6aeb278 | ||
|
|
7123dc305f | ||
|
|
43cb632528 | ||
|
|
74c5b12a33 | ||
|
|
acebed10b0 | ||
|
|
21f5c36c05 | ||
|
|
35c297e0d1 | ||
|
|
e672890bd4 | ||
|
|
a3f2dc5e76 | ||
|
|
a19db4fd2d | ||
|
|
982d136151 | ||
|
|
377de59df9 | ||
|
|
b394fb1e86 | ||
|
|
a5171ce093 | ||
|
|
c5dbaccea8 | ||
|
|
0c9d1e91bb | ||
|
|
a1ee0cb5d0 | ||
|
|
a2d41247fe | ||
|
|
97bb38e4f3 | ||
|
|
8e8ae26828 | ||
|
|
e241e91a84 | ||
|
|
ea277cf972 | ||
|
|
046e7ac4c3 | ||
|
|
beea8d9edf | ||
|
|
1c928bb139 | ||
|
|
987e6ad4c6 | ||
|
|
b7732e53c6 | ||
|
|
bfb34cf236 | ||
|
|
50b9bebf98 | ||
|
|
e21624ed52 | ||
|
|
86a8085239 | ||
|
|
19c756a971 | ||
|
|
b76c6ed444 | ||
|
|
bd07342689 | ||
|
|
be1d7309fd | ||
|
|
9471fad7bb | ||
|
|
642bf60f45 | ||
|
|
7245e32876 | ||
|
|
dcc1a2dc51 | ||
|
|
2b3874d8e9 | ||
|
|
29f9c311d2 | ||
|
|
adae78e747 | ||
|
|
5b5f3233d8 | ||
|
|
e6889798ff | ||
|
|
a5ae6f7013 | ||
|
|
0ac2df8102 | ||
|
|
ec5e7607f0 | ||
|
|
7588026640 | ||
|
|
ad366ee928 | ||
|
|
1fc38e941e | ||
|
|
b80c4ed921 | ||
|
|
1de9634c44 | ||
|
|
d8386bfbe8 | ||
|
|
3a02ad7836 | ||
|
|
d36716bdb6 | ||
|
|
970f4b028d | ||
|
|
6077da0df8 | ||
|
|
e77e0c54f0 | ||
|
|
4040f8ba89 | ||
|
|
3ac0dfb2ad | ||
|
|
b8e4947adb | ||
|
|
d1f2dfca05 | ||
|
|
c6a9b48aa0 | ||
|
|
fd690592ba | ||
|
|
8a66ee7565 | ||
|
|
f42d375cc7 | ||
|
|
68beb0d419 | ||
|
|
50d2814044 | ||
|
|
8ddd27c258 | ||
|
|
ac78eada0a | ||
|
|
6b0395dec8 | ||
|
|
5bb1432450 | ||
|
|
dc854cbd10 | ||
|
|
3128f2dace | ||
|
|
6ba494999c | ||
|
|
050ff5d8cd | ||
|
|
9c8b0611ea | ||
|
|
777d5ab3b7 | ||
|
|
39fecb3ffe | ||
|
|
4cd0702cbb | ||
|
|
998688573d | ||
|
|
a6833f5a6f | ||
|
|
a162d697da | ||
|
|
f281e9691d | ||
|
|
cbc2ee3cd1 | ||
|
|
4f469d0d3c | ||
|
|
e77a49ace6 | ||
|
|
8bde2092c6 | ||
|
|
7edfaad04e | ||
|
|
ae5576f2a3 | ||
|
|
b934b2b6ca | ||
|
|
87648f340b | ||
|
|
679abc6e3e | ||
|
|
dd930261bf | ||
|
|
92a0faf475 | ||
|
|
3ce6b43018 | ||
|
|
220858926f | ||
|
|
d908a1b0d2 | ||
|
|
b361cc0630 | ||
|
|
cd3663e041 | ||
|
|
c53e927a55 | ||
|
|
7bbb15a535 | ||
|
|
0dddcbcfa1 | ||
|
|
3175b75192 | ||
|
|
6703b78457 | ||
|
|
7a61bbc297 | ||
|
|
dd6003172d | ||
|
|
338f3d536f | ||
|
|
27804384db | ||
|
|
7e5069c7d1 | ||
|
|
0a813f9b53 | ||
|
|
c79c21e965 | ||
|
|
0c0bcb87f9 | ||
|
|
f537852469 | ||
|
|
ff422d112b | ||
|
|
bf92280ed9 | ||
|
|
d1e57e85b6 | ||
|
|
f300b6c8a2 | ||
|
|
4c0addd929 | ||
|
|
a08bc79ae4 | ||
|
|
8ad00084e1 | ||
|
|
0d275b3259 | ||
|
|
ede4b99bcd | ||
|
|
e99748e45f | ||
|
|
114fdd6f91 | ||
|
|
245675d409 | ||
|
|
e156f0a2e8 | ||
|
|
519d76d809 | ||
|
|
b4fa70d1d5 | ||
|
|
3825fb24f4 | ||
|
|
79e51b543a | ||
|
|
54fe9dde70 | ||
|
|
d224ffd6d3 | ||
|
|
1e1a458add | ||
|
|
0844f60343 | ||
|
|
c8f9d6e6df | ||
|
|
95a392ccfa | ||
|
|
2972a23f19 | ||
|
|
f55a39eab6 | ||
|
|
8611328a03 | ||
|
|
08dc05c504 | ||
|
|
174f796b56 | ||
|
|
85949a0464 | ||
|
|
1b711e2c08 | ||
|
|
60ea415361 | ||
|
|
01613da38f | ||
|
|
d6e6e78acc | ||
|
|
0e5e439f69 | ||
|
|
fc78f68fa7 | ||
|
|
2f6d81ce2c | ||
|
|
08a600167a | ||
|
|
9779e706c5 | ||
|
|
b8e9d853e5 | ||
|
|
4041746240 | ||
|
|
c96a65b21d | ||
|
|
d67018b6d7 | ||
|
|
f7e47b5120 | ||
|
|
5bc28b63a6 | ||
|
|
62452b61b1 | ||
|
|
cb84e4c7b6 | ||
|
|
60ef816f0c | ||
|
|
d367cf9972 | ||
|
|
05973672e4 | ||
|
|
c4398e92e1 | ||
|
|
68b8ba691f | ||
|
|
d52cf455a9 | ||
|
|
fee822c689 | ||
|
|
228a1ad0da | ||
|
|
1ce42d1301 | ||
|
|
3d2a4d8c38 | ||
|
|
803164791f | ||
|
|
2b452a18df | ||
|
|
efcdac75e4 | ||
|
|
c30c9a7360 | ||
|
|
ce0da3fb80 | ||
|
|
728c20c184 | ||
|
|
e10475679b | ||
|
|
d9af0461ef | ||
|
|
2e4791d377 | ||
|
|
d1244e02db | ||
|
|
867d1841e9 | ||
|
|
3232900bdc | ||
|
|
077de9f539 | ||
|
|
b37f4bf0df | ||
|
|
64dd83e8fb | ||
|
|
5a615970c2 | ||
|
|
2a5cb85199 | ||
|
|
5f1f27a4f9 | ||
|
|
0d9f398de2 | ||
|
|
b0e84952c8 | ||
|
|
3df8cf3c13 | ||
|
|
975e5a89af | ||
|
|
7102615eaa | ||
|
|
68fa273c75 | ||
|
|
f8ecf0ec0b | ||
|
|
888aba0b04 | ||
|
|
2216136de3 | ||
|
|
4163c5534a | ||
|
|
3ddcaddd79 | ||
|
|
b029e0d790 | ||
|
|
6f2d51f3ff | ||
|
|
ecf8c4ffbe | ||
|
|
4b769da90b | ||
|
|
4247c8fdc6 | ||
|
|
7777cd5238 | ||
|
|
cb959e93f2 | ||
|
|
30979b460b | ||
|
|
901d1ecf4a | ||
|
|
c84a38f2b2 | ||
|
|
ed461bc22f | ||
|
|
28368e6e12 | ||
|
|
8247855330 | ||
|
|
7cd6237f16 | ||
|
|
ca9bdc337a | ||
|
|
e6d2394d54 | ||
|
|
3d43665603 | ||
|
|
f135ffbe49 | ||
|
|
3d99bc7166 | ||
|
|
3789edf53a | ||
|
|
4d57134aa2 | ||
|
|
e3d9a120cb | ||
|
|
043791fc91 | ||
|
|
33a37be378 | ||
|
|
32ce6e7a08 | ||
|
|
3046cfd6ee | ||
|
|
9758632bf0 | ||
|
|
0c7f926421 | ||
|
|
ab6fc9303b | ||
|
|
4e37a5f08e | ||
|
|
426b4dde54 | ||
|
|
d3cc79a28c | ||
|
|
b0a826aaca | ||
|
|
9c0e678e50 | ||
|
|
bbb2571215 | ||
|
|
d2a0d75906 | ||
|
|
e16dc941d2 | ||
|
|
fa61e7bacb | ||
|
|
439a182103 | ||
|
|
7eb478cc6a | ||
|
|
4341c1fbc8 | ||
|
|
e8039715e7 | ||
|
|
3cacfdfd6f | ||
|
|
7b2cd20cff | ||
|
|
79a5fab39e | ||
|
|
b03d687f75 | ||
|
|
61ec341c27 | ||
|
|
22835108be | ||
|
|
1e654fbcd6 | ||
|
|
49c4776dbd | ||
|
|
41c4bc69cc | ||
|
|
6eea921820 | ||
|
|
3d97b317f2 | ||
|
|
7ba24968d8 | ||
|
|
57046d714b | ||
|
|
0526445d3c | ||
|
|
b62d1a303c | ||
|
|
e25c5cc6c7 | ||
|
|
e761cd7c27 | ||
|
|
617548f6b6 | ||
|
|
2f93af4d6f | ||
|
|
c2504bb6db | ||
|
|
145f89eded | ||
|
|
6c0e4b6a48 | ||
|
|
87d1eefc86 | ||
|
|
a87ec6f2e7 | ||
|
|
a9d5478bcd | ||
|
|
5eae51a1b4 | ||
|
|
ac94ab6914 | ||
|
|
38673a85c9 | ||
|
|
d75951c869 | ||
|
|
67d36a9e28 | ||
|
|
de8e8915ff | ||
|
|
2161816ef4 | ||
|
|
d046bdec65 | ||
|
|
cddd7d1562 | ||
|
|
15a3104904 | ||
|
|
e1ca6eca0f | ||
|
|
cc0137bdc9 | ||
|
|
86a620bc08 | ||
|
|
3ba9c3b4a8 | ||
|
|
8dc5ada553 | ||
|
|
9fe744c545 | ||
|
|
cc29eb0f9b | ||
|
|
901edde634 | ||
|
|
44dd6d499d | ||
|
|
375f09cbaf | ||
|
|
d4e5a31de4 | ||
|
|
f3fa3864b2 | ||
|
|
1c978c2497 | ||
|
|
0f4ec2b3e2 | ||
|
|
e30b1abaa4 | ||
|
|
16cbee1fb2 | ||
|
|
56932f2b56 | ||
|
|
6a8f6b8370 | ||
|
|
7fa8e65015 | ||
|
|
4f50b8c7d5 | ||
|
|
eca85d9978 | ||
|
|
f0ea2eb39b | ||
|
|
4c5b229680 | ||
|
|
83251cbc43 | ||
|
|
c2f20cce32 | ||
|
|
ec5ab44519 | ||
|
|
ed6d21a05a | ||
|
|
a2f3913fe5 | ||
|
|
d66c0ef308 | ||
|
|
0f9f6746ed | ||
|
|
9b534a0dc1 | ||
|
|
1ce73c1238 | ||
|
|
3b5b7ca01d | ||
|
|
ce64a7816d | ||
|
|
37e095a93c | ||
|
|
300f6a103f | ||
|
|
e7cb5a6b92 | ||
|
|
67296fabf7 | ||
|
|
d868f7fb26 | ||
|
|
1555d98f7d | ||
|
|
3a02941b38 | ||
|
|
f25de1ffbe | ||
|
|
008e90324f | ||
|
|
73dcc2eb26 | ||
|
|
eae2540a31 | ||
|
|
2a7fc8edb6 | ||
|
|
cd67299b1d | ||
|
|
ff38008207 | ||
|
|
76e903a782 | ||
|
|
25698f5d9b | ||
|
|
c729731c7e | ||
|
|
9d877ec97a | ||
|
|
b4cead757d | ||
|
|
dcc2f28079 | ||
|
|
97aa00e18b | ||
|
|
f076581bed | ||
|
|
5d8f789791 | ||
|
|
4faabb7770 | ||
|
|
1cffff77bf | ||
|
|
8f9b3df681 | ||
|
|
449575e0f7 | ||
|
|
b7d47779d6 | ||
|
|
fe443c8a89 | ||
|
|
f01a772d8d | ||
|
|
76d068aa23 | ||
|
|
b151db0843 | ||
|
|
5a3f432d89 | ||
|
|
8dcef5ea9f | ||
|
|
05299952a9 | ||
|
|
5966b9ff23 | ||
|
|
1917c04baf | ||
|
|
4c20ac0b1c | ||
|
|
6bd548cc22 | ||
|
|
3e7f0ad0a8 | ||
|
|
785f72ecd6 | ||
|
|
62cdb1a797 | ||
|
|
24e17c4e4f | ||
|
|
d664c0166d | ||
|
|
05ebe821f2 | ||
|
|
6c4ac347db | ||
|
|
e8024ce341 | ||
|
|
afc17f41f6 | ||
|
|
49c80f0e0b | ||
|
|
10a6a3056a | ||
|
|
69af62cde0 | ||
|
|
0cc8b5def0 | ||
|
|
fc52e73153 | ||
|
|
ce67e460c6 | ||
|
|
85e5f4d2c0 | ||
|
|
6237afe3cd | ||
|
|
cfddb6f04e | ||
|
|
3bf1a77381 | ||
|
|
c4d430c62d | ||
|
|
d583ee2de3 | ||
|
|
406a381ef4 | ||
|
|
1d82308c43 | ||
|
|
5c1595b1fd | ||
|
|
667cfb999b | ||
|
|
f0b5954c54 | ||
|
|
9b0e817635 | ||
|
|
b93d9a6b6e | ||
|
|
5bb9117fde | ||
|
|
84d35c1a37 | ||
|
|
ba42e90502 | ||
|
|
10d1ec1bb2 | ||
|
|
d6ccd106e6 | ||
|
|
7f7eaea2b1 | ||
|
|
d33b1eb394 | ||
|
|
feb984649f | ||
|
|
aada49e548 | ||
|
|
7f3c34178b | ||
|
|
c79dd43105 | ||
|
|
cb53ff880d | ||
|
|
3d88b3c74b | ||
|
|
d3c66714c2 | ||
|
|
e8ca443697 | ||
|
|
94089113ef | ||
|
|
1847fc2060 | ||
|
|
7ca21b52a0 | ||
|
|
444beb4908 | ||
|
|
791869ca9e | ||
|
|
33c8406b8a | ||
|
|
b6212c4e98 | ||
|
|
fcd2409ee3 | ||
|
|
dad060d0c9 | ||
|
|
9444195de7 | ||
|
|
b25f3094b7 | ||
|
|
ec378a8fc5 | ||
|
|
9adf9a841c | ||
|
|
8bb8e011f3 | ||
|
|
3b66522a5e | ||
|
|
dd2b718974 | ||
|
|
50121153dd | ||
|
|
189b7e20db | ||
|
|
5e572a8c6a | ||
|
|
dd654fcd8d | ||
|
|
0f9a529647 | ||
|
|
5854adb806 | ||
|
|
c60c738c7e | ||
|
|
ec40d20e6a | ||
|
|
3e2b777c30 | ||
|
|
4a02981c09 | ||
|
|
3382ccc7bf | ||
|
|
581303c923 | ||
|
|
63dd79e23c | ||
|
|
2346230d36 | ||
|
|
1c451fb98a | ||
|
|
7eeb43a83b | ||
|
|
fa2188f087 | ||
|
|
df1b87e3ac | ||
|
|
62a0a44049 | ||
|
|
0ae25d2212 | ||
|
|
620e6012da | ||
|
|
330b17bff8 | ||
|
|
1969ce562a | ||
|
|
5ea15cc7eb | ||
|
|
88aa76bcab | ||
|
|
401120fa28 | ||
|
|
534113b303 | ||
|
|
53df7835d3 | ||
|
|
ee9afb7fa0 | ||
|
|
6163ab8c42 | ||
|
|
e73724a576 | ||
|
|
e71284d887 | ||
|
|
11d95b89e1 | ||
|
|
ee7052ebc2 | ||
|
|
9059642738 | ||
|
|
fe8e3b4489 | ||
|
|
4e00e5b995 | ||
|
|
05ae0ca9d7 | ||
|
|
ac0116109b | ||
|
|
710ed9dd0e | ||
|
|
c75d2435fa | ||
|
|
050aa40e20 | ||
|
|
cb6d87302d | ||
|
|
f9e725bcf8 | ||
|
|
a56d622df7 | ||
|
|
267f5105b2 | ||
|
|
4e2ffad70d | ||
|
|
7db3bde933 | ||
|
|
a9564f8f38 | ||
|
|
9c5a130ab4 | ||
|
|
c40398df5d | ||
|
|
27fdd9e715 | ||
|
|
59697095b1 | ||
|
|
a16f5f5505 | ||
|
|
922d484a33 | ||
|
|
f63f24ac9d | ||
|
|
e7521fe30c | ||
|
|
f6579ac434 | ||
|
|
e1b4a0b720 | ||
|
|
9c43d661be | ||
|
|
d2d8bff9f7 | ||
|
|
a1beb13094 | ||
|
|
37d66848d6 | ||
|
|
991399fe4f | ||
|
|
27855456a0 | ||
|
|
0687ed9ec4 | ||
|
|
632976e71f | ||
|
|
98addef614 | ||
|
|
c72c9934b5 | ||
|
|
6362f24ae9 | ||
|
|
dc2d2fe25b | ||
|
|
bbc007e6cf | ||
|
|
3dfd195630 | ||
|
|
e3f72baab3 | ||
|
|
1bb19d0d9e | ||
|
|
bc1b69a836 | ||
|
|
26c7f0b017 | ||
|
|
d058dbe9af | ||
|
|
c1c237402a | ||
|
|
bb20de6c24 | ||
|
|
8d7af7b01d | ||
|
|
fd1c122cd4 | ||
|
|
3925ba3eef | ||
|
|
4306072ca7 | ||
|
|
15fba23ad7 | ||
|
|
04753226e5 | ||
|
|
3fda97a9bd | ||
|
|
26afdd105f | ||
|
|
7c50251c37 | ||
|
|
3de9d07769 | ||
|
|
d60899e362 | ||
|
|
7c8019ac5b | ||
|
|
1258d0cf7d | ||
|
|
2264369e9e | ||
|
|
e18a8556c1 | ||
|
|
5436011f8e | ||
|
|
ce0fd3cec2 | ||
|
|
bd349f5abc | ||
|
|
7fdea613ff | ||
|
|
16beeb2e88 | ||
|
|
ae74f8ea83 | ||
|
|
88b748e67b | ||
|
|
3254069999 | ||
|
|
0bfa9f0c58 | ||
|
|
498ffa806d | ||
|
|
9bfed2a80d | ||
|
|
ec902c5762 | ||
|
|
bb1d31f877 | ||
|
|
09f938fb72 | ||
|
|
a3e9192998 | ||
|
|
80b7c0b4c9 | ||
|
|
f01d6938f3 | ||
|
|
ab95d707a3 | ||
|
|
6475b4029a | ||
|
|
f646b3dc5c | ||
|
|
bcef0802e4 | ||
|
|
47099cc77b | ||
|
|
793102f3ad | ||
|
|
6f29bdf355 | ||
|
|
edf53c8a0f | ||
|
|
24859e33c1 | ||
|
|
ebcf4315f7 | ||
|
|
135e2bb10e | ||
|
|
72a256eccf | ||
|
|
1e991c0526 | ||
|
|
978e6b9dde | ||
|
|
a2acb3cbb7 | ||
|
|
623fcce3d1 | ||
|
|
c99ef4a549 | ||
|
|
c629ce6ef8 | ||
|
|
35743de0df | ||
|
|
5cf182cf98 | ||
|
|
01022546e8 | ||
|
|
83be42f4ea | ||
|
|
ab9fec0186 | ||
|
|
c6dd32ea7b | ||
|
|
1d4cd538ac | ||
|
|
dc99f725f9 | ||
|
|
2f804f3eac | ||
|
|
4b9688af04 | ||
|
|
897b896c11 | ||
|
|
3600b46824 | ||
|
|
9266fe63b9 | ||
|
|
a3ea52968f | ||
|
|
a06f2aeb8b | ||
|
|
f4a56d4e19 | ||
|
|
f3f7ff5622 | ||
|
|
dbf016adaf | ||
|
|
0e5108bd08 | ||
|
|
cf1180faa9 | ||
|
|
1b25275b2e | ||
|
|
886c694db7 | ||
|
|
3d38a2aa14 | ||
|
|
51d879ba6f | ||
|
|
91ae9d75f7 | ||
|
|
42559364e4 | ||
|
|
9d6bb306e7 | ||
|
|
c92860ae49 | ||
|
|
b012624b67 | ||
|
|
5f1d2f02ee | ||
|
|
46cb869237 | ||
|
|
054c497678 | ||
|
|
8362424976 | ||
|
|
f7a0dc00e8 | ||
|
|
e49c4655a6 | ||
|
|
1dcb3b1529 | ||
|
|
cc474f39d8 | ||
|
|
113d36f5dd | ||
|
|
63c0841c32 | ||
|
|
4739e2e3b2 | ||
|
|
aa316091e6 | ||
|
|
2297721228 | ||
|
|
03ab912495 | ||
|
|
d12dfabd0b | ||
|
|
ed748bbebd | ||
|
|
e85858d22d | ||
|
|
0afa9717e5 | ||
|
|
1e2e3498c6 | ||
|
|
59a01b816c | ||
|
|
508f46af42 | ||
|
|
1af865a577 | ||
|
|
74834af222 | ||
|
|
f55e33f303 | ||
|
|
5b8394d685 | ||
|
|
34fc30b8db | ||
|
|
6e575df40b | ||
|
|
2689b22674 | ||
|
|
5570f3bdcf | ||
|
|
cc7edbe3a7 | ||
|
|
fcb77360e1 | ||
|
|
c49e84efc6 | ||
|
|
98a4a8d8c6 | ||
|
|
5f73532d62 | ||
|
|
d52f9f2a92 | ||
|
|
1cee01a22a | ||
|
|
68dff71512 | ||
|
|
3d285e105e | ||
|
|
f92948d65c | ||
|
|
c4d771f2c6 | ||
|
|
566ffbdde2 | ||
|
|
5cf18ae17c | ||
|
|
4891721cc0 | ||
|
|
49b189e7a9 | ||
|
|
a9c5a3105f | ||
|
|
c13f5a9b04 | ||
|
|
12f805c020 | ||
|
|
c2a5f437fd | ||
|
|
1c986e2bf6 | ||
|
|
09fd1c7628 | ||
|
|
ecf44deecf | ||
|
|
5fb843268f | ||
|
|
ffb90c2b4b | ||
|
|
bb55544f25 | ||
|
|
2e9e9a60bf | ||
|
|
f7e77cd6cb | ||
|
|
a7a94789e6 | ||
|
|
864ac1a270 | ||
|
|
fbec6d8873 | ||
|
|
5f647a932d | ||
|
|
6e5046c0d4 | ||
|
|
3c9a8e38d3 | ||
|
|
b3a3196136 | ||
|
|
3229b7d106 | ||
|
|
4213c377f8 | ||
|
|
86365ebd44 | ||
|
|
20cf685174 | ||
|
|
aeb5a7b251 | ||
|
|
47a50bb449 | ||
|
|
a2a5b67496 | ||
|
|
a94cc5bdab | ||
|
|
526c5a6dd8 | ||
|
|
3f5239706f | ||
|
|
d2761abd04 | ||
|
|
57983b54d2 | ||
|
|
703cf8434a | ||
|
|
aa4375e25f | ||
|
|
7d3a672b55 | ||
|
|
c08b30a060 | ||
|
|
d579e39b40 | ||
|
|
b147d4bdeb | ||
|
|
48faa8a813 | ||
|
|
ec646809dd | ||
|
|
ab48a28635 | ||
|
|
3fd7afbb57 | ||
|
|
4074929c6b | ||
|
|
d73e84ea6c | ||
|
|
753f1dc7b6 | ||
|
|
9464667323 | ||
|
|
4d82f2f22d | ||
|
|
e3d28e703f | ||
|
|
5f39a314b5 | ||
|
|
43caadc472 | ||
|
|
f2ce8b9f3c | ||
|
|
735e91e636 | ||
|
|
4df94d1f20 | ||
|
|
70440520e1 | ||
|
|
e49e2d5093 | ||
|
|
f0ac301417 | ||
|
|
168ff90e38 | ||
|
|
5e4f529b26 | ||
|
|
0d1bd3e298 | ||
|
|
70f826b737 | ||
|
|
8bd5af3fd2 | ||
|
|
dbbae87cd3 | ||
|
|
83fd42aff2 | ||
|
|
93c9383a92 | ||
|
|
7c490d8b72 | ||
|
|
b4b5e86a14 | ||
|
|
e166b97b8f | ||
|
|
829f382726 | ||
|
|
4ef103063d | ||
|
|
fa66e50193 | ||
|
|
255ad96c8b | ||
|
|
a12fa1177b | ||
|
|
6fa7cc8201 | ||
|
|
2fff5418a9 | ||
|
|
2e805cd5a3 | ||
|
|
61eaa89de6 | ||
|
|
aa0c021f8b | ||
|
|
4fd02db079 | ||
|
|
88bbc7e8c1 | ||
|
|
af0ba32dd9 | ||
|
|
938d295bf3 | ||
|
|
f82667066f | ||
|
|
03a7fe1282 | ||
|
|
0c0504ffd1 | ||
|
|
e4900ce87b | ||
|
|
04de87722b | ||
|
|
a95e28c085 | ||
|
|
9fbcfb0f0f | ||
|
|
f24a3442fb | ||
|
|
918a945e3b | ||
|
|
3b96b5779b | ||
|
|
a93b20428e | ||
|
|
0522024f6d | ||
|
|
f27f135a61 | ||
|
|
6a76136878 | ||
|
|
1766d28fc2 | ||
|
|
f51d944bb3 | ||
|
|
cabe240e7e | ||
|
|
e72fb39c4d | ||
|
|
0ca30e0e87 | ||
|
|
cc362393be | ||
|
|
40bfd827d2 | ||
|
|
a4046fbf6e | ||
|
|
28605f2687 | ||
|
|
2085d1bbba | ||
|
|
08db03800e | ||
|
|
04b7e0cde9 | ||
|
|
401260d3ca | ||
|
|
53e0c05290 | ||
|
|
cdbb89482e | ||
|
|
e290635ba5 | ||
|
|
e340e6f5e3 | ||
|
|
2d950e8f3a | ||
|
|
4c70d72ba2 | ||
|
|
80edc4c4e0 | ||
|
|
4d07a1aab6 | ||
|
|
e2cd357319 | ||
|
|
620b57bfa6 | ||
|
|
934c9d3df8 | ||
|
|
f034e8bb37 | ||
|
|
93f80c6837 | ||
|
|
a54177fee0 | ||
|
|
0e6ad42923 | ||
|
|
b0beb74011 | ||
|
|
4857fecc41 | ||
|
|
8405d960be | ||
|
|
b9101c9fb2 | ||
|
|
e457e6a2f2 | ||
|
|
f3416fa03e | ||
|
|
3e5ab2b1e1 | ||
|
|
5c0bc9a7c2 | ||
|
|
2adfa55acd | ||
|
|
8125e8afcd | ||
|
|
e328f18558 | ||
|
|
cbdfa9079c | ||
|
|
e08c4515a7 | ||
|
|
650aa16b89 | ||
|
|
11d908218b | ||
|
|
3627a7dc93 | ||
|
|
1e1c8cc4ff | ||
|
|
d616bc09c9 | ||
|
|
f1cef44d5d | ||
|
|
3795be4750 | ||
|
|
2bb66a7526 | ||
|
|
28a472782f | ||
|
|
a4da002352 | ||
|
|
94fdc2beee | ||
|
|
dd4a01d9f8 | ||
|
|
a2a6c67350 | ||
|
|
2152ca7ba6 | ||
|
|
40e4d236f4 | ||
|
|
0450cd080d | ||
|
|
1eaac79d63 | ||
|
|
19c0305ed9 | ||
|
|
f0d14a966a | ||
|
|
37e6ccdc1a | ||
|
|
06cea99b40 | ||
|
|
1851336862 | ||
|
|
461eb273d9 | ||
|
|
470edc4d70 | ||
|
|
26132a2a56 | ||
|
|
d92bd16042 | ||
|
|
1c7dfa6c91 | ||
|
|
3a3fed4314 | ||
|
|
82bdb76d75 | ||
|
|
066f3ea078 | ||
|
|
9d760a21d5 | ||
|
|
976c795ac6 | ||
|
|
ed320e4e24 | ||
|
|
3111738700 | ||
|
|
1fa0bada23 | ||
|
|
11eca7e58b | ||
|
|
4b50e2f14d | ||
|
|
63faba9603 | ||
|
|
3e213699e0 | ||
|
|
c88ff07691 | ||
|
|
b53aa5cf6e | ||
|
|
2641b9b3fe | ||
|
|
3a2a73992c | ||
|
|
b82b17a701 | ||
|
|
a6202edcd1 | ||
|
|
6eac0cb75d | ||
|
|
83672d6138 | ||
|
|
51dadf72d0 | ||
|
|
0cbf61acb3 | ||
|
|
399c7435ac | ||
|
|
d51fae7878 | ||
|
|
9750e25ad5 | ||
|
|
2f9b2f0e8f | ||
|
|
8deaf22544 | ||
|
|
b192f43187 | ||
|
|
8cb8d1303c | ||
|
|
5237348975 | ||
|
|
72e2f6757e | ||
|
|
cf059e7f86 | ||
|
|
44d69216b6 | ||
|
|
c38bf09af0 | ||
|
|
9373d47e86 | ||
|
|
29350628c3 | ||
|
|
83d1a68879 | ||
|
|
f188408099 | ||
|
|
449ab3a798 | ||
|
|
21504d1417 | ||
|
|
3060b496e3 | ||
|
|
bd35539fa1 | ||
|
|
df6447e3ad | ||
|
|
24fd898f0d | ||
|
|
1aa6238288 | ||
|
|
c16c4788da | ||
|
|
0c35daab85 | ||
|
|
4a19639e92 | ||
|
|
36cceea677 | ||
|
|
4dbc76790a | ||
|
|
b32a344a21 | ||
|
|
3896ab822f | ||
|
|
cfa4ba57d4 | ||
|
|
da051e0269 | ||
|
|
3eda77a91f | ||
|
|
5c2f4be5dd | ||
|
|
435b501db9 | ||
|
|
5a27ffef5f | ||
|
|
02256d9a45 | ||
|
|
7e069009d6 | ||
|
|
3c25cda4c0 | ||
|
|
70d7ad0b1a | ||
|
|
e793b2f661 | ||
|
|
917ea3e401 | ||
|
|
5a54dd666f | ||
|
|
733ec2c145 | ||
|
|
e386b03b90 | ||
|
|
25d5d51085 | ||
|
|
50c4301a34 | ||
|
|
0f60c0696b | ||
|
|
3ec2947c4f | ||
|
|
e89162838e | ||
|
|
72181090a5 | ||
|
|
72b2a5cc0d | ||
|
|
1eaeec8100 | ||
|
|
660db3b3ab | ||
|
|
89d2fcb81e | ||
|
|
ccda623840 | ||
|
|
a6e7dff61e | ||
|
|
86d1bbe8ff | ||
|
|
a10cb06f14 | ||
|
|
dd9a62818b | ||
|
|
c0e936675c | ||
|
|
b0b788b7dc | ||
|
|
18f0f3ecac | ||
|
|
e7d745ac94 | ||
|
|
24abf7f0ed | ||
|
|
36fb097d1d | ||
|
|
35ef5fd0d3 | ||
|
|
9a08f6534b | ||
|
|
885dd2053b | ||
|
|
6f6f280bdd | ||
|
|
a3e8fd374f | ||
|
|
f91c1f4180 | ||
|
|
d85746c1b9 | ||
|
|
5a17075eef | ||
|
|
6cab47fb55 | ||
|
|
93c5413790 | ||
|
|
f2db7baeba | ||
|
|
a507991808 | ||
|
|
7c86f90ac6 | ||
|
|
1e9b772692 | ||
|
|
096ab52216 | ||
|
|
88c3cd5cdd | ||
|
|
99a911a220 | ||
|
|
3218ab971b | ||
|
|
274e3c1f7f | ||
|
|
f8916a6e35 | ||
|
|
73f20d01e4 | ||
|
|
2fd3a875b6 | ||
|
|
68cba8d3b2 | ||
|
|
6b28fd405e | ||
|
|
3bccbabe53 | ||
|
|
c97c66ed8a | ||
|
|
4b212232c8 | ||
|
|
ac3a8edf2b | ||
|
|
b581025bbe | ||
|
|
7bc5331747 | ||
|
|
2415976475 | ||
|
|
8d0d0f0449 | ||
|
|
16b00ed160 | ||
|
|
df73a420f9 | ||
|
|
1e4d57f275 | ||
|
|
19a238c8d3 | ||
|
|
5ffd8a79eb | ||
|
|
04fbc82d7c | ||
|
|
3f105f7b8b | ||
|
|
b9193a5562 | ||
|
|
e1fa188244 | ||
|
|
80ad87671a | ||
|
|
b6d5a6ec2e | ||
|
|
759398d804 | ||
|
|
c1b30db3d1 | ||
|
|
0c8bfc39ef | ||
|
|
3815fddb27 | ||
|
|
b585a64a38 | ||
|
|
58e58c192f | ||
|
|
5939344378 | ||
|
|
ad85ee3531 | ||
|
|
349f19fef7 | ||
|
|
d5777a024e | ||
|
|
b7f4ee6171 | ||
|
|
a83c4e3970 | ||
|
|
5a767dae34 | ||
|
|
9f93d30b99 | ||
|
|
dff525edc6 | ||
|
|
26da431320 | ||
|
|
cde4622693 | ||
|
|
5ede7ecc69 | ||
|
|
b607d1e628 | ||
|
|
d7e36bdf93 | ||
|
|
2b8b185b5b | ||
|
|
927ebcbec9 | ||
|
|
ea1397de63 | ||
|
|
ce1f5c6204 | ||
|
|
652114c7b5 | ||
|
|
17cd2128fd | ||
|
|
bc4378cb3e | ||
|
|
513878dfef | ||
|
|
753d5529b2 | ||
|
|
9f217b88e4 | ||
|
|
d53faa8c01 | ||
|
|
a934760960 | ||
|
|
82914fc2aa | ||
|
|
db687197de | ||
|
|
efd713dc61 | ||
|
|
3f3c7cfe88 | ||
|
|
73ca285b7a | ||
|
|
168d25c020 | ||
|
|
e8ae5486c8 | ||
|
|
f049b8b915 | ||
|
|
4e755dc537 | ||
|
|
5351310a38 | ||
|
|
211ca43a82 | ||
|
|
e5235e7f22 | ||
|
|
e72298f0b4 | ||
|
|
3abf5c65c6 | ||
|
|
268acb0b88 | ||
|
|
196b3b873f | ||
|
|
4d9801a372 | ||
|
|
bd710ba665 | ||
|
|
afe369c876 | ||
|
|
206007bbce | ||
|
|
8ad05b92c0 | ||
|
|
735da2a855 | ||
|
|
980077f559 | ||
|
|
12053e75bb | ||
|
|
62372ed4c5 | ||
|
|
e5caf37697 | ||
|
|
befc5a9530 | ||
|
|
1e00407864 | ||
|
|
73038efccf | ||
|
|
6d37e19b40 | ||
|
|
2c33ef2b0d | ||
|
|
6c30e0836f | ||
|
|
5f77ca31bd | ||
|
|
f7c82d6b09 | ||
|
|
86dd9aa42a | ||
|
|
5daca270e4 | ||
|
|
e18813a4bf | ||
|
|
4aa7e211f3 | ||
|
|
419dc7edfb | ||
|
|
eaa84a6b39 | ||
|
|
1d0503d0e4 | ||
|
|
f7f98aa9a3 | ||
|
|
575d14261a | ||
|
|
9aab606deb | ||
|
|
2e11681b52 | ||
|
|
8cca6637f7 | ||
|
|
82e076378c | ||
|
|
94ddad3c49 | ||
|
|
d35dbca18b | ||
|
|
7468d6147a | ||
|
|
7c78d749de | ||
|
|
85dd99a3c4 | ||
|
|
0a9c0234e2 | ||
|
|
fad77ba5a0 | ||
|
|
12723f3216 | ||
|
|
a43140515f | ||
|
|
3e3cc8c541 | ||
|
|
a85141ace2 | ||
|
|
c33280bbb2 | ||
|
|
df3aa04787 | ||
|
|
4bd25a0d4a | ||
|
|
7fadf4c6e1 | ||
|
|
d1538508e8 | ||
|
|
b24d786933 | ||
|
|
8f69b87dd1 | ||
|
|
9b1da8c311 | ||
|
|
8287063aab | ||
|
|
e4a8258acf | ||
|
|
5e88043c7b | ||
|
|
bedf9112fb | ||
|
|
03681d23c5 | ||
|
|
21683db0b8 | ||
|
|
978d829150 | ||
|
|
cc05572a35 | ||
|
|
c5bb310613 | ||
|
|
77551b1fed | ||
|
|
70728c274e | ||
|
|
cee4714665 | ||
|
|
c3eca3b626 | ||
|
|
01e4cd2e78 | ||
|
|
b99d01ad7b | ||
|
|
bf0213907e | ||
|
|
eff5b6459d | ||
|
|
8e29b5eed6 | ||
|
|
c91748da15 | ||
|
|
f04f9dc262 | ||
|
|
e873cdab7e | ||
|
|
f9b6fd6ac5 | ||
|
|
aa191e110c | ||
|
|
dd09907925 | ||
|
|
da4810672d | ||
|
|
f772f59d7c | ||
|
|
1964fb90d5 | ||
|
|
5945f2860a | ||
|
|
f45da049b9 | ||
|
|
c0beab8a5d | ||
|
|
cabeb13adb | ||
|
|
e2e9721d5f | ||
|
|
4e9deab605 | ||
|
|
9bb048fb01 | ||
|
|
6849f80506 | ||
|
|
5597f4e2e0 | ||
|
|
35e9508bde | ||
|
|
45fbcec805 | ||
|
|
4c8da70ef3 | ||
|
|
ed5da5cd4a | ||
|
|
dc5fccdbcd | ||
|
|
91aea333c7 | ||
|
|
a0de01cff1 | ||
|
|
a41ed34193 | ||
|
|
49e8811c18 | ||
|
|
488563a82e | ||
|
|
d76d50f30e | ||
|
|
4685aef88d | ||
|
|
a106510924 | ||
|
|
9d54503ef7 | ||
|
|
b1449eebf3 | ||
|
|
b213453062 | ||
|
|
076c0321cf | ||
|
|
4d71b73f38 | ||
|
|
b20ffdf7db | ||
|
|
ef018e22d6 | ||
|
|
3fa290a257 | ||
|
|
cdde530b60 | ||
|
|
aa608510d0 | ||
|
|
009fd63ce9 | ||
|
|
bea352855a | ||
|
|
51e8a80ca3 | ||
|
|
8a543d4513 | ||
|
|
945e180a6f | ||
|
|
b93fa332d3 | ||
|
|
9e947f742d | ||
|
|
033e90f8b7 | ||
|
|
be576176c5 | ||
|
|
4da3e8a4d8 | ||
|
|
db2bf537ea | ||
|
|
9a4fdcaef2 | ||
|
|
ca40360f7d | ||
|
|
799e705ff8 | ||
|
|
a1b18c7f97 | ||
|
|
9958a6e1bf | ||
|
|
1fc6d8aca7 | ||
|
|
3e9ec2d943 | ||
|
|
1420def1c3 | ||
|
|
3b4184e765 | ||
|
|
4ce9102f93 | ||
|
|
eb27ec2234 | ||
|
|
b70e25d348 | ||
|
|
772c0bbe1a | ||
|
|
144021c053 | ||
|
|
59486cd55d | ||
|
|
afe3904ea3 | ||
|
|
8abd3ed3f1 | ||
|
|
53ed510c92 | ||
|
|
4ec46a2ebd | ||
|
|
db6f948499 | ||
|
|
05c73011f5 | ||
|
|
3b733d01f1 | ||
|
|
ebf21296d4 | ||
|
|
6f83ac4822 | ||
|
|
d358924324 | ||
|
|
f9a3606ca2 | ||
|
|
33299ad51e | ||
|
|
8752182e7e | ||
|
|
0551ac8ead | ||
|
|
6d5a11bd4d | ||
|
|
ce19d84247 | ||
|
|
f24aa45a3b | ||
|
|
64a28a7e75 | ||
|
|
249a755312 | ||
|
|
a3d51a013c | ||
|
|
839def9959 | ||
|
|
fd432a7100 | ||
|
|
60a07ce1e7 | ||
|
|
88c5700d87 | ||
|
|
d59993abf6 | ||
|
|
0754011909 | ||
|
|
376bb66cab | ||
|
|
588e15c633 | ||
|
|
93b8ad20d7 | ||
|
|
e51b3d760d | ||
|
|
91f3bc4488 | ||
|
|
3e80a99bbf | ||
|
|
37cdb55e79 | ||
|
|
58b66c0c95 | ||
|
|
e5f9db86a1 | ||
|
|
f138f99356 | ||
|
|
c42f4b9814 | ||
|
|
0a9fb886e3 | ||
|
|
3c4577201f | ||
|
|
816421188f | ||
|
|
5b15d2c4c6 | ||
|
|
4bc7165452 | ||
|
|
82d6531e8c | ||
|
|
d6209035c3 | ||
|
|
1d7799f981 | ||
|
|
51291a61e6 | ||
|
|
0841603be0 | ||
|
|
59ba6a0b1e | ||
|
|
53eda46043 | ||
|
|
cbc9fb7d08 | ||
|
|
1f479b20be | ||
|
|
f00b8e9522 | ||
|
|
c7dd271516 | ||
|
|
a947a61d65 | ||
|
|
0122f1cc5e | ||
|
|
acb905a3e6 | ||
|
|
7422eb5598 | ||
|
|
e721166f95 | ||
|
|
5a48130fa4 | ||
|
|
b60fe1ad10 | ||
|
|
1405b0147c | ||
|
|
222a7a57bc | ||
|
|
cddf9f0242 | ||
|
|
3e17ff5e7b | ||
|
|
04973094f2 | ||
|
|
018a6cb84a | ||
|
|
44825af0c0 | ||
|
|
cfb3607052 | ||
|
|
c5ec928aac | ||
|
|
8d0183a9fb | ||
|
|
ecd4079871 | ||
|
|
3ed975f2e6 | ||
|
|
c6deb537d5 | ||
|
|
e7b3d806a7 | ||
|
|
d1a0778b48 | ||
|
|
378634567f | ||
|
|
ed56ed2be0 | ||
|
|
648aa7e3b0 | ||
|
|
73ff41f2b2 | ||
|
|
3837466cb3 | ||
|
|
b97a5ef888 | ||
|
|
2ff1276ebb | ||
|
|
227cf5de85 | ||
|
|
ccf52be431 | ||
|
|
07713e988c | ||
|
|
f934318625 | ||
|
|
6fb90abd75 | ||
|
|
27cc33888a | ||
|
|
95af901808 | ||
|
|
c5a7f84250 | ||
|
|
a71d28500d | ||
|
|
436fd16f3a | ||
|
|
ca34bf42f6 | ||
|
|
fbf2315f57 | ||
|
|
72f50dcb6b | ||
|
|
fd4c2f79a7 | ||
|
|
72f9335213 | ||
|
|
53d97047a3 | ||
|
|
2ba3666e23 | ||
|
|
4a1d379ab4 | ||
|
|
73167e1e30 | ||
|
|
ffc13f5de3 | ||
|
|
9ba23d49d8 | ||
|
|
222a6c48a7 | ||
|
|
e33208e6ec | ||
|
|
af8781eaa7 | ||
|
|
167b1a8d2e | ||
|
|
0a7aff507c | ||
|
|
103532aad9 | ||
|
|
16939e9fd5 | ||
|
|
4ef6169041 | ||
|
|
9ebee42118 | ||
|
|
234d3997b1 | ||
|
|
3ba0bcea4e | ||
|
|
701855344e | ||
|
|
71b627fbc7 | ||
|
|
5a4fc2c690 | ||
|
|
0d67db52a2 | ||
|
|
d971554201 | ||
|
|
8fd7d7176e | ||
|
|
675575eed9 | ||
|
|
2122cde293 | ||
|
|
b68a554bba | ||
|
|
33043c7133 | ||
|
|
2e0f606a7a | ||
|
|
87878dd6a7 | ||
|
|
5296e073cc | ||
|
|
7bfb7d6f6e | ||
|
|
b5069cc7c2 | ||
|
|
3b6791f51a | ||
|
|
0b0be77e02 | ||
|
|
60db10559e | ||
|
|
76b066ba4a | ||
|
|
a28db32369 | ||
|
|
2523632391 | ||
|
|
53548c356a | ||
|
|
565904ff5d | ||
|
|
e0c5545f8c | ||
|
|
bc21285289 | ||
|
|
bbf8d757cd | ||
|
|
318d504fad | ||
|
|
fd5038148c | ||
|
|
693ca9291e | ||
|
|
cfd8afc226 | ||
|
|
3e52ca7600 | ||
|
|
482522e802 | ||
|
|
8b5b6a01c6 | ||
|
|
5614891d92 | ||
|
|
b9b4961f3c | ||
|
|
7b83b20339 | ||
|
|
e4403dd316 | ||
|
|
3f267fe6c9 | ||
|
|
3229471485 | ||
|
|
62bac1adf9 | ||
|
|
82becfd52a | ||
|
|
92f035545b | ||
|
|
74d8ea7dcb | ||
|
|
ac3f087279 | ||
|
|
1cc4eb98c1 | ||
|
|
e99bdf8f24 | ||
|
|
b4f521a141 | ||
|
|
1e23bc09f1 | ||
|
|
e3ec90405d | ||
|
|
41c87794a4 | ||
|
|
e62d2d4fda | ||
|
|
93adaa0f52 | ||
|
|
263a5d2067 | ||
|
|
f0a5005794 | ||
|
|
577457c8ab | ||
|
|
c0c450c444 | ||
|
|
1e1e0b0f51 | ||
|
|
a19204a1d5 | ||
|
|
1d139bfdfe | ||
|
|
75072decec | ||
|
|
0cf2ad6901 | ||
|
|
ccbd57a0c0 | ||
|
|
92e6c8c445 | ||
|
|
1e966f1d59 | ||
|
|
6872c2194e | ||
|
|
5b6a0216db | ||
|
|
e9a7194cd6 | ||
|
|
26898b9122 | ||
|
|
3e00e490cf | ||
|
|
c02ed17ebc | ||
|
|
fb559d66fe | ||
|
|
25dce64c3b | ||
|
|
6f19fde76e | ||
|
|
33ae91f49c | ||
|
|
99c179e29a | ||
|
|
1dbcb5a027 | ||
|
|
54d613e00e | ||
|
|
1f8aa90482 | ||
|
|
c9dcbef014 | ||
|
|
68086ec3f1 | ||
|
|
f62078d02b | ||
|
|
ab1d8594ea | ||
|
|
c368ec3c38 | ||
|
|
1a15782686 | ||
|
|
3bd0aeef77 | ||
|
|
b463baedd2 | ||
|
|
ae77c41dab | ||
|
|
807d909acd | ||
|
|
fa4f5f526e | ||
|
|
edff43cdb3 | ||
|
|
46fe45528c | ||
|
|
b4b53da6a4 | ||
|
|
41fd270080 | ||
|
|
410bb3cdca | ||
|
|
bc6fc24fbd | ||
|
|
c039f06c2b | ||
|
|
520effbbb7 | ||
|
|
a42d780724 | ||
|
|
da92255dd6 | ||
|
|
cce3d3bce8 | ||
|
|
f524e99290 | ||
|
|
ba19fc7cf3 | ||
|
|
22c3de582f | ||
|
|
48896e67cb | ||
|
|
16cd91eb02 | ||
|
|
7e03774b8e | ||
|
|
a04f6e3858 | ||
|
|
96eb1be556 | ||
|
|
f8e20a1405 | ||
|
|
c67c3a6861 | ||
|
|
d04897e011 | ||
|
|
558ae1a2ea | ||
|
|
64bffb82b1 | ||
|
|
81ac390eab | ||
|
|
0db556fac2 | ||
|
|
2793df06c4 | ||
|
|
e7b448e2bc | ||
|
|
d2bc72d54f | ||
|
|
83b22b4861 | ||
|
|
d42a949602 | ||
|
|
83e1512b59 | ||
|
|
ba6a1ec584 | ||
|
|
6685e583f2 | ||
|
|
d6032c912e | ||
|
|
25527ecc21 | ||
|
|
e0e7bd42cc | ||
|
|
fbc1af56ed | ||
|
|
8ff108db9e | ||
|
|
1dfcf960d3 | ||
|
|
9bdc51a3fb | ||
|
|
dbf3bcfacf | ||
|
|
3d5b269315 | ||
|
|
48f97da9cc | ||
|
|
9c954fbd81 | ||
|
|
c6bd41654f | ||
|
|
d65a74bb23 | ||
|
|
ff08bca042 | ||
|
|
a5d3d2e3b4 | ||
|
|
496a0667ee | ||
|
|
9be688b667 | ||
|
|
f3d9008c61 | ||
|
|
649a43c978 | ||
|
|
50568704ca | ||
|
|
b66b4dec83 | ||
|
|
8d0e807435 | ||
|
|
bf05ed7caf | ||
|
|
b5080eff00 | ||
|
|
c474769dd6 | ||
|
|
a6ae01da0b | ||
|
|
2bf4c44dbf | ||
|
|
5ca0fbba13 | ||
|
|
4cd84b2019 | ||
|
|
c502a45cf5 | ||
|
|
9e66914306 | ||
|
|
d33d27ee82 | ||
|
|
e2b13573ae | ||
|
|
ec74f5f1de | ||
|
|
5dee56debc | ||
|
|
5623fc992d | ||
|
|
1d28bfc570 | ||
|
|
084327e973 | ||
|
|
b2885efdc1 | ||
|
|
b65a75f138 | ||
|
|
0ee7f50bb4 | ||
|
|
02ce21bc18 | ||
|
|
3ba487bb00 |
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts}]
|
||||
[*.{js,ts,tsx}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
27
.github/actions/build-electron/action.yml
vendored
27
.github/actions/build-electron/action.yml
vendored
@@ -74,7 +74,7 @@ runs:
|
||||
|
||||
- name: Update build info
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run chore:update-build-info
|
||||
run: pnpm run chore:update-build-info
|
||||
|
||||
# Critical debugging configuration
|
||||
- name: Run electron-forge build with enhanced logging
|
||||
@@ -86,7 +86,8 @@ runs:
|
||||
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
|
||||
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
|
||||
run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
||||
TARGET_ARCH: ${{ inputs.arch }}
|
||||
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
||||
|
||||
# Add DMG signing step
|
||||
- name: Sign DMG
|
||||
@@ -162,3 +163,25 @@ runs:
|
||||
echo "Found ZIP: $zip_file"
|
||||
echo "Note: ZIP files are not code signed, but their contents should be"
|
||||
fi
|
||||
|
||||
- name: Sign the RPM
|
||||
if: inputs.os == 'linux'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import
|
||||
|
||||
# Import the key into RPM for verification
|
||||
gpg --export -a > pubkey
|
||||
rpm --import pubkey
|
||||
rm pubkey
|
||||
|
||||
# Sign the RPM
|
||||
rpm_file=$(find ./apps/desktop/upload -name "*.rpm" -print -quit)
|
||||
rpmsign --define "_gpg_name Trilium Notes Signing Key <triliumnotes@outlook.com>" --addsign "$rpm_file"
|
||||
rpm -Kv "$rpm_file"
|
||||
|
||||
# Validate code signing
|
||||
if ! rpm -K "$rpm_file" | grep -q "digests signatures OK"; then
|
||||
echo .rpm file not signed
|
||||
exit 1
|
||||
fi
|
||||
|
||||
4
.github/actions/build-server/action.yml
vendored
4
.github/actions/build-server/action.yml
vendored
@@ -10,7 +10,7 @@ runs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -23,7 +23,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm run chore:update-build-info
|
||||
pnpm nx --project=server package
|
||||
pnpm run --filter server package
|
||||
- name: Prepare artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
103
.github/actions/deploy-to-cloudflare-pages/action.yml
vendored
Normal file
103
.github/actions/deploy-to-cloudflare-pages/action.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
name: "Deploy to Cloudflare Pages"
|
||||
description: "Deploys to Cloudflare Pages on either a temporary branch with preview comment, or on the production version if on the main branch."
|
||||
inputs:
|
||||
project_name:
|
||||
description: "CloudFlare Pages project name"
|
||||
comment_body:
|
||||
description: "The message to display when deployment is ready"
|
||||
default: "Deployment is ready."
|
||||
required: false
|
||||
production_url:
|
||||
description: "The URL to mention as the production URL."
|
||||
required: true
|
||||
deploy_dir:
|
||||
description: "The directory from which to deploy."
|
||||
required: true
|
||||
cloudflare_api_token:
|
||||
description: "The Cloudflare API token to use for deployment."
|
||||
required: true
|
||||
cloudflare_account_id:
|
||||
description: "The Cloudflare account ID to use for deployment."
|
||||
required: true
|
||||
github_token:
|
||||
description: "The GitHub token to use for posting PR comments."
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Install wrangler globally to avoid workspace issues
|
||||
- name: Install Wrangler
|
||||
shell: bash
|
||||
run: npm install -g wrangler
|
||||
|
||||
# Deploy using Wrangler (use pre-installed wrangler)
|
||||
- name: Deploy to Cloudflare Pages
|
||||
id: deploy
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ inputs.cloudflare_api_token }}
|
||||
accountId: ${{ inputs.cloudflare_account_id }}
|
||||
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=${{ github.ref_name }}
|
||||
wranglerVersion: '' # Use pre-installed version
|
||||
|
||||
# Deploy preview for PRs
|
||||
- name: Deploy Preview to Cloudflare Pages
|
||||
id: preview-deployment
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ inputs.cloudflare_api_token }}
|
||||
accountId: ${{ inputs.cloudflare_account_id }}
|
||||
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=pr-${{ github.event.pull_request.number }}
|
||||
wranglerVersion: '' # Use pre-installed version
|
||||
|
||||
# Post deployment URL as PR comment
|
||||
- name: Comment PR with Preview URL
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
COMMENT_BODY: ${{ inputs.comment_body }}
|
||||
PRODUCTION_URL: ${{ inputs.production_url }}
|
||||
PROJECT_NAME: ${{ inputs.project_name }}
|
||||
with:
|
||||
github-token: ${{ inputs.github_token }}
|
||||
script: |
|
||||
const prNumber = context.issue.number;
|
||||
// Construct preview URL based on Cloudflare Pages pattern
|
||||
const projectName = process.env.PROJECT_NAME;
|
||||
const previewUrl = `https://pr-${prNumber}.${projectName}.pages.dev`;
|
||||
|
||||
// Check if we already commented
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const customMessage = process.env.COMMENT_BODY;
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes(customMessage)
|
||||
);
|
||||
|
||||
const mainUrl = process.env.PRODUCTION_URL;
|
||||
const commentBody = `${customMessage}!\n\n🔗 Preview URL: ${previewUrl}\n📖 Production URL: ${mainUrl}\n\n✅ All checks passed\n\n_This preview will be updated automatically with new commits._`;
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: prNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
40
.github/instructions/nx.instructions.md
vendored
40
.github/instructions/nx.instructions.md
vendored
@@ -1,40 +0,0 @@
|
||||
---
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
// This file is automatically generated by Nx Console
|
||||
|
||||
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
|
||||
|
||||
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
|
||||
|
||||
# General Guidelines
|
||||
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
|
||||
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
|
||||
|
||||
# Generation Guidelines
|
||||
If the user wants to generate something, use the following flow:
|
||||
|
||||
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
|
||||
- get the available generators using the 'nx_generators' tool
|
||||
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
|
||||
- get generator details using the 'nx_generator_schema' tool
|
||||
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
|
||||
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
|
||||
- open the generator UI using the 'nx_open_generate_ui' tool
|
||||
- wait for the user to finish the generator
|
||||
- read the generator log file using the 'nx_read_generator_log' tool
|
||||
- use the information provided in the log file to answer the user's question or continue with what they were doing
|
||||
|
||||
# Running Tasks Guidelines
|
||||
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
|
||||
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
|
||||
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
|
||||
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
|
||||
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
|
||||
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
|
||||
|
||||
|
||||
|
||||
1
.github/workflows/checks.yml
vendored
1
.github/workflows/checks.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if PRs have conflicts
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
with:
|
||||
dirtyLabel: "merge-conflicts"
|
||||
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
||||
|
||||
128
.github/workflows/deploy-docs.yml
vendored
Normal file
128
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
# GitHub Actions workflow for deploying MkDocs documentation to Cloudflare Pages
|
||||
# This workflow builds and deploys your MkDocs site when changes are pushed to main
|
||||
name: Deploy MkDocs Documentation
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master # Also support master branch
|
||||
# Only run when docs files change
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'README.md' # README is synced to docs/index.md
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- 'scripts/fix-mkdocs-structure.ts'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'README.md' # README is synced to docs/index.md
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- 'scripts/fix-mkdocs-structure.ts'
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy MkDocs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info and mkdocs-git-revision-date plugin
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'requirements-docs.txt'
|
||||
|
||||
- name: Install MkDocs and Dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements-docs.txt
|
||||
env:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: 1
|
||||
|
||||
# Setup pnpm before fixing docs structure
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
# Setup Node.js with pnpm
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Install Node.js dependencies for the TypeScript script
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Fix Documentation Structure
|
||||
run: |
|
||||
# Fix duplicate navigation entries by moving overview pages to index.md
|
||||
pnpm run chore:fix-mkdocs-structure
|
||||
|
||||
- name: Build MkDocs Site
|
||||
run: |
|
||||
# Build with strict mode but allow expected warnings
|
||||
mkdocs build --verbose || {
|
||||
EXIT_CODE=$?
|
||||
# Check if the only issue is expected warnings
|
||||
if mkdocs build 2>&1 | grep -E "WARNING.*(README|not found)" && \
|
||||
[ $(mkdocs build 2>&1 | grep -c "ERROR") -eq 0 ]; then
|
||||
echo "✅ Build succeeded with expected warnings"
|
||||
mkdocs build --verbose
|
||||
else
|
||||
echo "❌ Build failed with unexpected errors"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
}
|
||||
|
||||
- name: Fix HTML Links
|
||||
run: |
|
||||
# Remove .md extensions from links in generated HTML
|
||||
pnpm tsx ./scripts/fix-html-links.ts site
|
||||
|
||||
- name: Validate Built Site
|
||||
run: |
|
||||
# Basic validation that important files exist
|
||||
test -f site/index.html || (echo "ERROR: site/index.html not found" && exit 1)
|
||||
test -f site/sitemap.xml || (echo "ERROR: site/sitemap.xml not found" && exit 1)
|
||||
test -d site/assets || (echo "ERROR: site/assets directory not found" && exit 1)
|
||||
echo "✅ Site validation passed"
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
with:
|
||||
project_name: "trilium-docs"
|
||||
comment_body: "📚 Documentation preview is ready"
|
||||
production_url: "https://docs.triliumnotes.org"
|
||||
deploy_dir: "site"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
37
.github/workflows/dev.yml
vendored
37
.github/workflows/dev.yml
vendored
@@ -19,45 +19,24 @@ permissions:
|
||||
pull-requests: write # for PR comments
|
||||
|
||||
jobs:
|
||||
check-affected:
|
||||
name: Check affected jobs (NX)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Check affected
|
||||
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
|
||||
|
||||
test_dev:
|
||||
name: Test development
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-affected
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run the unit tests
|
||||
run: pnpm run test:all
|
||||
|
||||
@@ -66,7 +45,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test_dev
|
||||
- check-affected
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -75,7 +53,7 @@ jobs:
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
- name: Trigger client build
|
||||
run: pnpm nx run client:build
|
||||
run: pnpm client:build
|
||||
- name: Send client bundle stats to RelativeCI
|
||||
if: false
|
||||
uses: relative-ci/agent-action@v3
|
||||
@@ -83,7 +61,7 @@ jobs:
|
||||
webpackStatsFile: ./apps/client/dist/webpack-stats.json
|
||||
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
|
||||
- name: Trigger server build
|
||||
run: pnpm nx run server:build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -95,7 +73,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build_docker
|
||||
- check-affected
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -112,7 +89,7 @@ jobs:
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
- name: Trigger build
|
||||
run: pnpm nx run server:build
|
||||
run: pnpm server:build
|
||||
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
14
.github/workflows/main-docker.yml
vendored
14
.github/workflows/main-docker.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
require-healthy: true
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm exec nx run server-e2e:e2e
|
||||
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e
|
||||
|
||||
- name: Upload Playwright trace
|
||||
if: failure()
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
@@ -152,12 +152,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run the TypeScript build
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
|
||||
- name: Run the TypeScript build
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
13
.github/workflows/nightly.yml
vendored
13
.github/workflows/nightly.yml
vendored
@@ -19,7 +19,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_UPLOAD_URL: https://uploads.github.com/repos/TriliumNext/Notes/releases/179589950/assets{?name,label}
|
||||
GITHUB_RELEASE_ID: 179589950
|
||||
|
||||
permissions:
|
||||
@@ -27,7 +26,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
nightly-electron:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
name: Deploy nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -51,13 +50,12 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Update nightly version
|
||||
run: npm run chore:ci-update-nightly-version
|
||||
- name: Run the build
|
||||
@@ -76,9 +74,10 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
uses: softprops/action-gh-release@v2.3.4
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@@ -97,7 +96,7 @@ jobs:
|
||||
path: apps/desktop/upload
|
||||
|
||||
nightly-server:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
name: Deploy server nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -119,7 +118,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
uses: softprops/action-gh-release@v2.3.4
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "apps/website/**"
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -19,14 +21,8 @@ jobs:
|
||||
filter: tree:0
|
||||
fetch-depth: 0
|
||||
|
||||
# This enables task distribution via Nx Cloud
|
||||
# Run this command as early as possible, before dependencies are installed
|
||||
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
|
||||
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
|
||||
# - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
@@ -34,10 +30,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
|
||||
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
|
||||
# - run: npx nx-cloud record -- echo Hello World
|
||||
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
|
||||
# When you enable task distribution, run the e2e-ci task instead of e2e
|
||||
- run: pnpm exec nx affected -t e2e --exclude desktop-e2e
|
||||
- run: pnpm --filter server-e2e e2e
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e report
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -30,18 +30,30 @@ jobs:
|
||||
image: win-signing
|
||||
shell: cmd
|
||||
forge_platform: win32
|
||||
# Exclude ARM64 Linux from default matrix to use native runner
|
||||
exclude:
|
||||
- arch: arm64
|
||||
os:
|
||||
name: linux
|
||||
# Add ARM64 Linux with native ubuntu-24.04-arm runner for better-sqlite3 compatibility
|
||||
include:
|
||||
- arch: arm64
|
||||
os:
|
||||
name: linux
|
||||
image: ubuntu-24.04-arm
|
||||
shell: bash
|
||||
forge_platform: linux
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-electron
|
||||
with:
|
||||
@@ -58,6 +70,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -114,7 +127,7 @@ jobs:
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
uses: softprops/action-gh-release@v2.3.4
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
48
.github/workflows/website.yml
vendored
Normal file
48
.github/workflows/website.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Deploy website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/website/**"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/website/**"
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build & deploy website
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter website --frozen-lockfile
|
||||
|
||||
- name: Build the website
|
||||
run: pnpm website:build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
with:
|
||||
project_name: "trilium-homepage"
|
||||
comment_body: "📚 Website preview is ready"
|
||||
production_url: "https://triliumnotes.org"
|
||||
deploy_dir: "apps/website/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
/.cache
|
||||
|
||||
# compiled output
|
||||
dist
|
||||
@@ -32,14 +33,11 @@ testem.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
test-output
|
||||
|
||||
apps/*/data
|
||||
apps/*/data*
|
||||
apps/*/out
|
||||
upload
|
||||
|
||||
@@ -47,4 +45,7 @@ upload
|
||||
*.tsbuildinfo
|
||||
|
||||
/result
|
||||
.svelte-kit
|
||||
.svelte-kit
|
||||
|
||||
# docs
|
||||
site/
|
||||
|
||||
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@@ -5,7 +5,6 @@
|
||||
"lokalise.i18n-ally",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"ms-playwright.playwright",
|
||||
"nrwl.angular-console",
|
||||
"redhat.vscode-yaml",
|
||||
"tobermory.es6-string-html",
|
||||
"vitest.explorer",
|
||||
|
||||
8
.vscode/mcp.json
vendored
8
.vscode/mcp.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"servers": {
|
||||
"nx-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:9461/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -35,6 +35,5 @@
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
},
|
||||
"nxConsole.generateAiAgentRules": true
|
||||
}
|
||||
}
|
||||
5
.vscode/snippets.code-snippets
vendored
5
.vscode/snippets.code-snippets
vendored
@@ -20,5 +20,10 @@
|
||||
"scope": "typescript",
|
||||
"prefix": "jqf",
|
||||
"body": ["private $${1:name}!: JQuery<HTMLElement>;"]
|
||||
},
|
||||
"region": {
|
||||
"scope": "css",
|
||||
"prefix": "region",
|
||||
"body": ["/* #region ${1:name} */\n$0\n/* #endregion */"]
|
||||
}
|
||||
}
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Overview
|
||||
|
||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
|
||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -14,12 +14,9 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
|
||||
|
||||
### Running Applications
|
||||
- `pnpm run server:start` - Start development server (http://localhost:8080)
|
||||
- `pnpm nx run server:serve` - Alternative server start command
|
||||
- `pnpm nx run desktop:serve` - Run desktop Electron app
|
||||
- `pnpm run server:start-prod` - Run server in production mode
|
||||
|
||||
### Building
|
||||
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
|
||||
- `pnpm run client:build` - Build client application
|
||||
- `pnpm run server:build` - Build server application
|
||||
- `pnpm run electron:build` - Build desktop application
|
||||
@@ -28,13 +25,8 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
|
||||
- `pnpm test:all` - Run all tests (parallel + sequential)
|
||||
- `pnpm test:parallel` - Run tests that can run in parallel
|
||||
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
|
||||
- `pnpm nx test <project>` - Run tests for specific project
|
||||
- `pnpm coverage` - Generate coverage reports
|
||||
|
||||
### Linting & Type Checking
|
||||
- `pnpm nx run <project>:lint` - Lint specific project
|
||||
- `pnpm nx run <project>:typecheck` - Type check specific project
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
@@ -94,7 +86,6 @@ Frontend uses a widget system (`apps/client/src/widgets/`):
|
||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
||||
|
||||
4. **Configuration**:
|
||||
- `nx.json` - NX workspace configuration
|
||||
- `package.json` - Project dependencies and scripts
|
||||
|
||||
## Note Types and Features
|
||||
@@ -154,7 +145,7 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
## Build System Notes
|
||||
- Uses NX for monorepo management with build caching
|
||||
- Uses pnpm for monorepo management
|
||||
- Vite for fast development builds
|
||||
- ESBuild for production optimization
|
||||
- pnpm workspaces for dependency management
|
||||
|
||||
98
README.md
98
README.md
@@ -1,11 +1,11 @@
|
||||
# Trilium Notes
|
||||
|
||||
 
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
|
||||
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md) | [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) | [Spanish](./docs/README-es.md)
|
||||
|
||||
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
|
||||
@@ -13,6 +13,23 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
|
||||
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
|
||||
Our documentation is available in multiple formats:
|
||||
- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
|
||||
- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
|
||||
- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
|
||||
|
||||
### Quick Links
|
||||
- [Getting Started Guide](https://docs.triliumnotes.org/)
|
||||
- [Installation Instructions](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
|
||||
- [Docker Setup](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
|
||||
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
|
||||
- [Basic Concepts and Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
|
||||
- [Patterns of Personal Knowledge Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
|
||||
|
||||
## 🎁 Features
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
|
||||
@@ -46,28 +63,15 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
|
||||
## ⚠️ Why TriliumNext?
|
||||
## ❓Why TriliumNext?
|
||||
|
||||
[The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620).
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
|
||||
|
||||
### Migrating from Trilium?
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Simply [install TriliumNext/Notes](#-installation) as usual and it will use your existing database.
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
||||
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
We're currently in the progress of moving the documentation to in-app (hit the `F1` key within Trilium). As a result, there may be some missing parts until we've completed the migration. If you'd prefer to navigate through the documentation within GitHub, you can navigate the [User Guide](./docs/User%20Guide/User%20Guide/) documentation.
|
||||
|
||||
Below are some quick links for your convenience to navigate the documentation:
|
||||
- [Server installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
|
||||
- [Docker installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
|
||||
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
|
||||
- [Concepts and Features - Note](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
|
||||
- [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
|
||||
|
||||
Until we finish reorganizing the documentation, you may also want to [browse the old documentation](https://triliumnext.github.io/Docs).
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
||||
|
||||
## 💬 Discuss with us
|
||||
|
||||
@@ -75,8 +79,8 @@ Feel free to join our official conversations. We would love to hear what feature
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
|
||||
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Notes/issues) (For bug reports and feature requests.)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
|
||||
|
||||
## 🏗 Installation
|
||||
|
||||
@@ -104,13 +108,15 @@ Currently only the latest versions of Chrome & Firefox are supported (and tested
|
||||
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
|
||||
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid). Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
|
||||
|
||||
See issue https://github.com/TriliumNext/Notes/issues/72 for more information on mobile app support.
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
|
||||
### Server
|
||||
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
|
||||
|
||||
|
||||
## 💻 Contribute
|
||||
@@ -140,7 +146,7 @@ Download the repository, install dependencies using `pnpm` and then run the envi
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
pnpm install
|
||||
pnpm nx run edit-docs:edit-docs
|
||||
pnpm edit-docs:edit-docs
|
||||
```
|
||||
|
||||
### Building the Executable
|
||||
@@ -149,27 +155,45 @@ Download the repository, install dependencies using `pnpm` and then build the de
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
pnpm install
|
||||
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
|
||||
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md).
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
|
||||
## 👏 Shoutouts
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
|
||||
* [zadam](https://github.com/zadam) for the original concept and implementation of the application.
|
||||
* [Larsa](https://github.com/LarsaSara) for designing the application icon.
|
||||
* [nriver](https://github.com/nriver) for his work on internationalization.
|
||||
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
|
||||
* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
|
||||
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
|
||||
|
||||
Trilium would not be possible without the technologies behind it:
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
Support for the TriliumNext organization will be possible in the near future. For now, you can:
|
||||
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors))) for a full list)
|
||||
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
|
||||
Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting.
|
||||
|
||||
Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via:
|
||||
|
||||
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
|
||||
- [PayPal](https://paypal.me/eliandoran)
|
||||
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
|
||||
|
||||
|
||||
## 🔑 License
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.2",
|
||||
"@stylistic/eslint-plugin": "5.2.3",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@stylistic/eslint-plugin": "5.4.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.33.0",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
@@ -49,7 +49,7 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.10",
|
||||
"typedoc": "0.28.13",
|
||||
"typedoc-plugin-missing-exports": "4.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
# Expires on: 2025-09-13
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NTc3MjE1OTksImp0aSI6ImFiN2E0NjZmLWJlZGMtNDNiYy1iMzU4LTk0NGQ0YWJhY2I3ZiIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOlsic2giLCJkcnVwYWwiXSwid2hpdGVMYWJlbCI6dHJ1ZSwiZmVhdHVyZXMiOlsiRFJVUCIsIkNNVCIsIkRPIiwiRlAiLCJTQyIsIlRPQyIsIlRQTCIsIlBPRSIsIkNDIiwiTUYiLCJTRUUiLCJFQ0giLCJFSVMiXSwidmMiOiI1MzlkOWY5YyJ9.2rvKPql4hmukyXhEtWPZ8MLxKvzPIwzCdykO653g7IxRRZy2QJpeRszElZx9DakKYZKXekVRAwQKgHxwkgbE_w
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1,16 +1,21 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.97.2",
|
||||
"version": "0.99.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"name": "Trilium Notes Team",
|
||||
"email": "contact@eliandoran.me",
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
"url": "https://github.com/TriliumNext/Trilium"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"test": "vitest",
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.33.0",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
@@ -19,7 +24,7 @@
|
||||
"@fullcalendar/multimonth": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
@@ -28,61 +33,48 @@
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.7",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"dayjs": "1.11.13",
|
||||
"dayjs": "1.11.18",
|
||||
"dayjs-plugin-utc": "0.1.2",
|
||||
"debounce": "2.2.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.50.1",
|
||||
"globals": "16.3.0",
|
||||
"i18next": "25.3.4",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "16.4.0",
|
||||
"i18next": "25.5.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.22",
|
||||
"katex": "0.16.23",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.5",
|
||||
"marked": "16.3.0",
|
||||
"mermaid": "11.12.0",
|
||||
"mind-elixir": "5.1.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.0",
|
||||
"preact": "10.27.2",
|
||||
"react-i18next": "16.0.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4",
|
||||
"photoswipe": "^5.4.4"
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.2.10",
|
||||
"@types/tabulator-tables": "6.2.11",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "18.0.1",
|
||||
"happy-dom": "19.0.2",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.1"
|
||||
},
|
||||
"nx": {
|
||||
"name": "client",
|
||||
"targets": {
|
||||
"serve": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"circular-deps": {
|
||||
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
}
|
||||
}
|
||||
"vite-plugin-static-copy": "3.1.3"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.png",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import froca from "../services/froca.js";
|
||||
import RootCommandExecutor from "./root_command_executor.js";
|
||||
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
|
||||
import Entrypoints from "./entrypoints.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { hasTouchBar } from "../services/utils.js";
|
||||
import zoomComponent from "./zoom.js";
|
||||
@@ -31,16 +31,14 @@ import { StartupChecks } from "./startup_checks.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
import { SqlExecuteResults } from "@triliumnext/commons";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||
}
|
||||
|
||||
interface RootWidget extends Component {
|
||||
render: () => JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
interface BeforeUploadListener extends Component {
|
||||
export interface BeforeUploadListener extends Component {
|
||||
beforeUnloadEvent(): boolean;
|
||||
}
|
||||
|
||||
@@ -85,7 +83,6 @@ export type CommandMappings = {
|
||||
focusTree: CommandData;
|
||||
focusOnTitle: CommandData;
|
||||
focusOnDetail: CommandData;
|
||||
focusOnSearchDefinition: Required<CommandData>;
|
||||
searchNotes: CommandData & {
|
||||
searchString?: string;
|
||||
ancestorNoteId?: string | null;
|
||||
@@ -93,6 +90,11 @@ export type CommandMappings = {
|
||||
closeTocCommand: CommandData;
|
||||
closeHlt: CommandData;
|
||||
showLaunchBarSubtree: CommandData;
|
||||
showHiddenSubtree: CommandData;
|
||||
showSQLConsoleHistory: CommandData;
|
||||
logout: CommandData;
|
||||
switchToMobileVersion: CommandData;
|
||||
switchToDesktopVersion: CommandData;
|
||||
showRevisions: CommandData & {
|
||||
noteId?: string | null;
|
||||
};
|
||||
@@ -114,7 +116,7 @@ export type CommandMappings = {
|
||||
openedFileUpdated: CommandData & {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastModifiedMs: number;
|
||||
lastModifiedMs?: number;
|
||||
filePath: string;
|
||||
};
|
||||
focusAndSelectTitle: CommandData & {
|
||||
@@ -138,6 +140,7 @@ export type CommandMappings = {
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
showShareSubtree: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -323,6 +326,7 @@ export type CommandMappings = {
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
@@ -526,7 +530,7 @@ export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMapp
|
||||
export class AppContext extends Component {
|
||||
isMainWindow: boolean;
|
||||
components: Component[];
|
||||
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
|
||||
beforeUnloadListeners: (WeakRef<BeforeUploadListener> | (() => boolean))[];
|
||||
tabManager!: TabManager;
|
||||
layout?: Layout;
|
||||
noteTreeWidget?: NoteTreeWidget;
|
||||
@@ -619,7 +623,7 @@ export class AppContext extends Component {
|
||||
component.triggerCommand(commandName, { $el: $(this) });
|
||||
});
|
||||
|
||||
this.child(rootWidget);
|
||||
this.child(rootWidget as Component);
|
||||
|
||||
this.triggerEvent("initialRenderComplete", {});
|
||||
}
|
||||
@@ -646,16 +650,20 @@ export class AppContext extends Component {
|
||||
}
|
||||
|
||||
getComponentByEl(el: HTMLElement) {
|
||||
return $(el).closest(".component").prop("component");
|
||||
return $(el).closest("[data-component-id]").prop("component");
|
||||
}
|
||||
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener) {
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
|
||||
if (typeof WeakRef !== "function") {
|
||||
// older browsers don't support WeakRef
|
||||
return;
|
||||
}
|
||||
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
if (typeof obj === "object") {
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
} else {
|
||||
this.beforeUnloadListeners.push(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,25 +673,29 @@ const appContext = new AppContext(window.glob.isMainWindow);
|
||||
$(window).on("beforeunload", () => {
|
||||
let allSaved = true;
|
||||
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref());
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => typeof wr === "function" || !!wr.deref());
|
||||
|
||||
for (const weakRef of appContext.beforeUnloadListeners) {
|
||||
const component = weakRef.deref();
|
||||
for (const listener of appContext.beforeUnloadListeners) {
|
||||
if (typeof listener === "object") {
|
||||
const component = listener.deref();
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
|
||||
allSaved = false;
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
allSaved = false;
|
||||
}
|
||||
} else {
|
||||
if (!listener()) {
|
||||
allSaved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSaved) {
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
return "some string";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import utils from "../services/utils.js";
|
||||
import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js";
|
||||
|
||||
type EventHandler = ((data: any) => void);
|
||||
|
||||
/**
|
||||
* Abstract class for all components in the Trilium's frontend.
|
||||
*
|
||||
@@ -19,6 +21,7 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
initialized: Promise<void> | null;
|
||||
parent?: TypedComponent<any>;
|
||||
_position!: number;
|
||||
private listeners: Record<string, EventHandler[]> | null = {};
|
||||
|
||||
constructor() {
|
||||
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
|
||||
@@ -76,6 +79,14 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// Handle React children.
|
||||
if (this.listeners?.[name]) {
|
||||
for (const listener of this.listeners[name]) {
|
||||
listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy children.
|
||||
for (const child of this.children) {
|
||||
const ret = child.handleEvent(name, data) as Promise<void>;
|
||||
|
||||
@@ -120,6 +131,35 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
registerHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners) {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
if (!this.listeners[name]) {
|
||||
this.listeners[name] = [];
|
||||
}
|
||||
|
||||
if (this.listeners[name].includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name].push(handler);
|
||||
}
|
||||
|
||||
removeHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners?.[name]?.includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name] = this.listeners[name]
|
||||
.filter(listener => listener !== handler);
|
||||
|
||||
if (!this.listeners[name].length) {
|
||||
delete this.listeners[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class Component extends TypedComponent<Component> {}
|
||||
|
||||
@@ -10,22 +10,7 @@ import bundleService from "../services/bundle.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
// TODO: Move somewhere else nicer.
|
||||
export type SqlExecuteResults = string[][][];
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface SqlExecuteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
results: SqlExecuteResults;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface CreateChildrenResponse {
|
||||
note: FNote;
|
||||
}
|
||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
@@ -34,7 +19,7 @@ export default class Entrypoints extends Component {
|
||||
|
||||
openDevToolsCommand() {
|
||||
if (utils.isElectron()) {
|
||||
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools();
|
||||
utils.dynamicRequire("@electron/remote").getCurrentWindow().webContents.toggleDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +109,7 @@ export default class Entrypoints extends Component {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
webContents.goToIndex(activeIndex - 1);
|
||||
} else {
|
||||
@@ -136,7 +121,7 @@ export default class Entrypoints extends Component {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
webContents.goToIndex(activeIndex + 1);
|
||||
} else {
|
||||
|
||||
@@ -43,8 +43,6 @@ export default class RootCommandExecutor extends Component {
|
||||
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(searchNote.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
appContext.triggerCommand("focusOnSearchDefinition", { ntxId: noteContext.ntxId });
|
||||
}
|
||||
|
||||
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
|
||||
|
||||
@@ -433,6 +433,9 @@ export default class TabManager extends Component {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
|
||||
// close dangling tooltips
|
||||
$("body > div.tooltip").remove();
|
||||
|
||||
const noteContextsToRemove = noteContextToRemove.getSubContexts();
|
||||
const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId);
|
||||
|
||||
@@ -600,18 +603,18 @@ export default class TabManager extends Component {
|
||||
}
|
||||
|
||||
async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
|
||||
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
|
||||
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId);
|
||||
|
||||
const removed = await this.removeNoteContext(ntxId);
|
||||
|
||||
if (removed) {
|
||||
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
|
||||
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
|
||||
}
|
||||
}
|
||||
|
||||
async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
|
||||
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
|
||||
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
|
||||
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId);
|
||||
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
|
||||
}
|
||||
|
||||
async reopenLastTabCommand() {
|
||||
|
||||
@@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component {
|
||||
this.$widget = $("<div>");
|
||||
|
||||
$(window).on("focusin", async (e) => {
|
||||
const $target = $(e.target);
|
||||
const focusedEl = e.target as unknown as HTMLElement;
|
||||
const $target = $(focusedEl);
|
||||
|
||||
this.$activeModal = $target.closest(".modal-dialog");
|
||||
const parentComponentEl = $target.closest(".component");
|
||||
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
|
||||
this.lastFocusedComponent = appContext.getComponentByEl(focusedEl);
|
||||
this.#refreshTouchBar();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,13 +8,10 @@ import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import options from "./services/options.js";
|
||||
import server from "./services/server.js";
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "./styles/gallery.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
@@ -48,6 +45,10 @@ if (utils.isElectron()) {
|
||||
electronContextMenu.setupContextMenu();
|
||||
}
|
||||
|
||||
if (utils.isPWA()) {
|
||||
initPWATopbarColor();
|
||||
}
|
||||
|
||||
function initOnElectron() {
|
||||
const electron: typeof Electron = utils.dynamicRequire("electron");
|
||||
electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName));
|
||||
@@ -116,3 +117,20 @@ function initDarkOrLightMode(style: CSSStyleDeclaration) {
|
||||
const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote;
|
||||
nativeTheme.themeSource = themeSource;
|
||||
}
|
||||
|
||||
function initPWATopbarColor() {
|
||||
const tracker = $("#background-color-tracker");
|
||||
|
||||
if (tracker.length) {
|
||||
const applyThemeColor = () => {
|
||||
let meta = $("meta[name='theme-color']");
|
||||
if (!meta.length) {
|
||||
meta = $(`<meta name="theme-color">`).appendTo($("head"));
|
||||
}
|
||||
meta.attr("content", tracker.css("color"));
|
||||
};
|
||||
|
||||
tracker.on("transitionend", applyThemeColor);
|
||||
applyThemeColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface NoteMetaData {
|
||||
/**
|
||||
* Note is the main node and concept in Trilium.
|
||||
*/
|
||||
class FNote {
|
||||
export default class FNote {
|
||||
private froca: Froca;
|
||||
|
||||
noteId!: string;
|
||||
@@ -256,18 +256,20 @@ class FNote {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
async getSubtreeNoteIds() {
|
||||
async getSubtreeNoteIds(includeArchived = false) {
|
||||
let noteIds: (string | string[])[] = [];
|
||||
for (const child of await this.getChildNotes()) {
|
||||
if (child.isArchived && !includeArchived) continue;
|
||||
|
||||
noteIds.push(child.noteId);
|
||||
noteIds.push(await child.getSubtreeNoteIds());
|
||||
noteIds.push(await child.getSubtreeNoteIds(includeArchived));
|
||||
}
|
||||
return noteIds.flat();
|
||||
}
|
||||
|
||||
async getSubtreeNotes() {
|
||||
const noteIds = await this.getSubtreeNoteIds();
|
||||
return this.froca.getNotes(noteIds);
|
||||
return (await this.froca.getNotes(noteIds));
|
||||
}
|
||||
|
||||
async getChildNotes() {
|
||||
@@ -905,8 +907,8 @@ class FNote {
|
||||
return this.getBlob();
|
||||
}
|
||||
|
||||
async getBlob() {
|
||||
return await this.froca.getBlob("notes", this.noteId);
|
||||
getBlob() {
|
||||
return this.froca.getBlob("notes", this.noteId);
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -1020,6 +1022,14 @@ class FNote {
|
||||
return this.noteId.startsWith("_options");
|
||||
}
|
||||
|
||||
isTriliumSqlite() {
|
||||
return this.mime === "text/x-sqlite;schema=trilium";
|
||||
}
|
||||
|
||||
isTriliumScript() {
|
||||
return this.mime.startsWith("application/javascript");
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides note's date metadata.
|
||||
*/
|
||||
@@ -1027,5 +1037,3 @@ class FNote {
|
||||
return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
export default FNote;
|
||||
|
||||
@@ -1,78 +1,47 @@
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import TitleBarButtonsWidget from "../widgets/title_bar_buttons.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import OwnedAttributeListWidget from "../widgets/ribbon_widgets/owned_attribute_list.js";
|
||||
import NoteActionsWidget from "../widgets/buttons/note_actions.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import RibbonContainer from "../widgets/containers/ribbon_container.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import InheritedAttributesWidget from "../widgets/ribbon_widgets/inherited_attribute_list.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import SearchDefinitionWidget from "../widgets/ribbon_widgets/search_definition.js";
|
||||
import SqlResultWidget from "../widgets/sql_result.js";
|
||||
import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js";
|
||||
import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import SearchResultWidget from "../widgets/search_result.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
|
||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.js";
|
||||
import NoteMapRibbonWidget from "../widgets/ribbon_widgets/note_map.js";
|
||||
import NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
|
||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import EditedNotesWidget from "../widgets/ribbon_widgets/edited_notes.js";
|
||||
import ShowTocWidgetButton from "../widgets/buttons/show_toc_widget_button.js";
|
||||
import ShowHighlightsListWidgetButton from "../widgets/buttons/show_highlights_list_widget_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RevisionsButton from "../widgets/buttons/revisions_button.js";
|
||||
import CodeButtonsWidget from "../widgets/floating_buttons/code_buttons.js";
|
||||
import ApiLogWidget from "../widgets/api_log.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import ScriptExecutorWidget from "../widgets/ribbon_widgets/script_executor.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
|
||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
|
||||
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
import type { AppContext } from "../components/app_context.js";
|
||||
import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import SwitchSplitOrientationButton from "../widgets/floating_buttons/switch_layout_button.js";
|
||||
import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_button.js";
|
||||
import PngExportButton from "../widgets/floating_buttons/png_export_button.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
|
||||
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -107,9 +76,9 @@ export default class DesktopLayout {
|
||||
new FlexContainer("row")
|
||||
.class("tab-row-container")
|
||||
.child(new FlexContainer("row").id("tab-row-left-spacer"))
|
||||
.optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true))
|
||||
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
|
||||
.child(new TabRowWidget().class("full-width"))
|
||||
.optChild(customTitleBarButtons, new TitleBarButtonsWidget())
|
||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||
.css("height", "40px")
|
||||
.css("background-color", "var(--launcher-pane-background-color)")
|
||||
.setParent(appContext)
|
||||
@@ -130,7 +99,7 @@ export default class DesktopLayout {
|
||||
new FlexContainer("column")
|
||||
.id("rest-pane")
|
||||
.css("flex-grow", "1")
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px"))
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px"))
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.filling()
|
||||
@@ -151,69 +120,30 @@ export default class DesktopLayout {
|
||||
.css("min-height", "50px")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget())
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(new MovePaneButton(true))
|
||||
.child(new MovePaneButton(false))
|
||||
.child(new ClosePaneButton())
|
||||
.child(new CreatePaneButton())
|
||||
.child(<MovePaneButton direction="left" />)
|
||||
.child(<MovePaneButton direction="right" />)
|
||||
.child(<ClosePaneButton />)
|
||||
.child(<CreatePaneButton />)
|
||||
)
|
||||
.child(
|
||||
new RibbonContainer()
|
||||
// the order of the widgets matter. Some of these want to "activate" themselves
|
||||
// when visible. When this happens to multiple of them, the first one "wins".
|
||||
// promoted attributes should always win.
|
||||
.ribbon(new ClassicEditorToolbar())
|
||||
.ribbon(new ScriptExecutorWidget())
|
||||
.ribbon(new SearchDefinitionWidget())
|
||||
.ribbon(new EditedNotesWidget())
|
||||
.ribbon(new BookPropertiesWidget())
|
||||
.ribbon(new NotePropertiesWidget())
|
||||
.ribbon(new FilePropertiesWidget())
|
||||
.ribbon(new ImagePropertiesWidget())
|
||||
.ribbon(new BasicPropertiesWidget())
|
||||
.ribbon(new OwnedAttributeListWidget())
|
||||
.ribbon(new InheritedAttributesWidget())
|
||||
.ribbon(new NotePathsWidget())
|
||||
.ribbon(new NoteMapRibbonWidget())
|
||||
.ribbon(new SimilarNotesWidget())
|
||||
.ribbon(new NoteInfoWidget())
|
||||
.button(new RevisionsButton())
|
||||
.button(new NoteActionsWidget())
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(<Ribbon />)
|
||||
.child(<SharedInfo />)
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.child(
|
||||
new FloatingButtons()
|
||||
.child(new RefreshButton())
|
||||
.child(new SwitchSplitOrientationButton())
|
||||
.child(new ToggleReadOnlyButton())
|
||||
.child(new EditButton())
|
||||
.child(new ShowTocWidgetButton())
|
||||
.child(new ShowHighlightsListWidgetButton())
|
||||
.child(new CodeButtonsWidget())
|
||||
.child(new RelationMapButtons())
|
||||
.child(new GeoMapButtons())
|
||||
.child(new CopyImageReferenceButton())
|
||||
.child(new SvgExportButton())
|
||||
.child(new PngExportButton())
|
||||
.child(new BacklinksWidget())
|
||||
.child(new ContextualHelpButton())
|
||||
.child(new HideFloatingButtonsButton())
|
||||
)
|
||||
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new SqlTableSchemasWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(new SearchResultWidget())
|
||||
.child(new SqlResultWidget())
|
||||
.child(new ScrollPaddingWidget())
|
||||
.child(<NoteList />)
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
.child(<ScrollPadding />)
|
||||
)
|
||||
.child(new ApiLogWidget())
|
||||
.child(<ApiLog />)
|
||||
.child(new FindWidget())
|
||||
.child(
|
||||
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
|
||||
@@ -232,11 +162,11 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(new CloseZenButton())
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
.child(new PasswordNoteSetDialog())
|
||||
.child(new UploadAttachmentsDialog());
|
||||
.child(<PasswordNoteSetDialog />)
|
||||
.child(<UploadAttachmentsDialog />);
|
||||
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
@@ -246,14 +176,18 @@ export default class DesktopLayout {
|
||||
let launcherPane;
|
||||
|
||||
if (isHorizontal) {
|
||||
launcherPane = new FlexContainer("row").css("height", "53px").class("horizontal").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true));
|
||||
launcherPane = new FlexContainer("row")
|
||||
.css("height", "53px")
|
||||
.class("horizontal")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<GlobalMenu isHorizontalLayout={true} />);
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(new GlobalMenuWidget(false))
|
||||
.child(<GlobalMenu isHorizontalLayout={false} />)
|
||||
.child(new LauncherContainer(false))
|
||||
.child(new LeftPaneToggleWidget(false));
|
||||
.child(<LeftPaneToggle isHorizontalLayout={false} />);
|
||||
}
|
||||
|
||||
launcherPane.id("launcher-pane");
|
||||
@@ -24,48 +24,48 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import { CallToActionDialog } from "../widgets/dialogs/call_to_action.jsx";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
.child(new BulkActionsDialog())
|
||||
.child(new AboutDialog())
|
||||
.child(new HelpDialog())
|
||||
.child(new RecentChangesDialog())
|
||||
.child(new BranchPrefixDialog())
|
||||
.child(new SortChildNotesDialog())
|
||||
.child(new IncludeNoteDialog())
|
||||
.child(new NoteTypeChooserDialog())
|
||||
.child(new JumpToNoteDialog())
|
||||
.child(new AddLinkDialog())
|
||||
.child(new CloneToDialog())
|
||||
.child(new MoveToDialog())
|
||||
.child(new ImportDialog())
|
||||
.child(new ExportDialog())
|
||||
.child(new MarkdownImportDialog())
|
||||
.child(new ProtectedSessionPasswordDialog())
|
||||
.child(new RevisionsDialog())
|
||||
.child(new DeleteNotesDialog())
|
||||
.child(new InfoDialog())
|
||||
.child(new ConfirmDialog())
|
||||
.child(new PromptDialog())
|
||||
.child(new IncorrectCpuArchDialog())
|
||||
.child(<BulkActionsDialog />)
|
||||
.child(<AboutDialog />)
|
||||
.child(<HelpDialog />)
|
||||
.child(<RecentChangesDialog />)
|
||||
.child(<BranchPrefixDialog />)
|
||||
.child(<SortChildNotesDialog />)
|
||||
.child(<IncludeNoteDialog />)
|
||||
.child(<NoteTypeChooserDialog />)
|
||||
.child(<JumpToNoteDialog />)
|
||||
.child(<AddLinkDialog />)
|
||||
.child(<CloneToDialog />)
|
||||
.child(<MoveToDialog />)
|
||||
.child(<ImportDialog />)
|
||||
.child(<ExportDialog />)
|
||||
.child(<MarkdownImportDialog />)
|
||||
.child(<ProtectedSessionPasswordDialog />)
|
||||
.child(<RevisionsDialog />)
|
||||
.child(<DeleteNotesDialog />)
|
||||
.child(<InfoDialog />)
|
||||
.child(<ConfirmDialog />)
|
||||
.child(<PromptDialog />)
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(new PopupEditorDialog()
|
||||
.child(new FlexContainer("row")
|
||||
.class("title-row")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget()))
|
||||
.child(new ClassicEditorToolbar())
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />))
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(true)))
|
||||
.child(new CallToActionDialog());
|
||||
.child(<NoteList displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
@@ -3,30 +3,27 @@ import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_button.js";
|
||||
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import MobileEditorToolbar from "../widgets/ribbon_widgets/mobile_editor_toolbar.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -135,38 +132,33 @@ export default class MobileLayout {
|
||||
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
|
||||
)
|
||||
.child(
|
||||
new ScreenContainer("detail", "column")
|
||||
new ScreenContainer("detail", "row")
|
||||
.id("detail-container")
|
||||
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9")
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.contentSized()
|
||||
.css("font-size", "larger")
|
||||
.css("align-items", "center")
|
||||
.child(new ToggleSidebarButtonWidget().contentSized())
|
||||
.child(new NoteTitleWidget().contentSized().css("position", "relative").css("padding-left", "0.5em"))
|
||||
.child(new MobileDetailMenuWidget(true).contentSized())
|
||||
new NoteWrapperWidget()
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.contentSized()
|
||||
.css("font-size", "larger")
|
||||
.css("align-items", "center")
|
||||
.child(<ToggleSidebarButton />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(<MobileDetailMenu />)
|
||||
)
|
||||
.child(<SharedInfoWidget />)
|
||||
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteList />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(
|
||||
new FloatingButtons()
|
||||
.child(new RefreshButton())
|
||||
.child(new EditButton())
|
||||
.child(new RelationMapButtons())
|
||||
.child(new SvgExportButton())
|
||||
.child(new BacklinksWidget())
|
||||
.child(new HideFloatingButtonsButton())
|
||||
)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(new FilePropertiesWidget().css("font-size", "smaller"))
|
||||
)
|
||||
.child(new MobileEditorToolbar())
|
||||
)
|
||||
)
|
||||
.child(
|
||||
@@ -174,10 +166,25 @@ export default class MobileLayout {
|
||||
.contentSized()
|
||||
.id("mobile-bottom-bar")
|
||||
.child(new TabRowWidget().css("height", "40px"))
|
||||
.child(new FlexContainer("row").class("horizontal").css("height", "53px").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)).id("launcher-pane"))
|
||||
.child(new FlexContainer("row")
|
||||
.class("horizontal")
|
||||
.css("height", "53px")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<GlobalMenuWidget isHorizontalLayout />)
|
||||
.id("launcher-pane"))
|
||||
)
|
||||
.child(new CloseZenButton());
|
||||
.child(<CloseZenModeButton />);
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
}
|
||||
}
|
||||
|
||||
function FilePropertiesWrapper() {
|
||||
const { note } = useNoteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
// @ts-ignore - module = undefined
|
||||
// Required for correct loading of scripts in Electron
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import keyboardActionService from "../services/keyboard_actions.js";
|
||||
import { KeyboardActionNames } from "@triliumnext/commons";
|
||||
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
|
||||
import note_tooltip from "../services/note_tooltip.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { should } from "vitest";
|
||||
|
||||
export interface ContextMenuOptions<T> {
|
||||
x: number;
|
||||
@@ -13,8 +15,13 @@ export interface ContextMenuOptions<T> {
|
||||
onHide?: () => void;
|
||||
}
|
||||
|
||||
interface MenuSeparatorItem {
|
||||
title: "----";
|
||||
export interface MenuSeparatorItem {
|
||||
kind: "separator";
|
||||
}
|
||||
|
||||
export interface MenuHeader {
|
||||
title: string;
|
||||
kind: "header";
|
||||
}
|
||||
|
||||
export interface MenuItemBadge {
|
||||
@@ -38,12 +45,13 @@ export interface MenuCommandItem<T> {
|
||||
handler?: MenuHandler<T>;
|
||||
items?: MenuItem<T>[] | null;
|
||||
shortcut?: string;
|
||||
keyboardShortcut?: KeyboardActionNames;
|
||||
spellingSuggestion?: string;
|
||||
checked?: boolean;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
|
||||
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem | MenuHeader;
|
||||
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
|
||||
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
|
||||
|
||||
@@ -148,14 +156,51 @@ class ContextMenu {
|
||||
.addClass("show");
|
||||
}
|
||||
|
||||
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[]) {
|
||||
for (const item of items) {
|
||||
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[], multicolumn = false) {
|
||||
let $group = $parent; // The current group or parent element to which items are being appended
|
||||
let shouldStartNewGroup = false; // If true, the next item will start a new group
|
||||
let shouldResetGroup = false; // If true, the next item will be the last one from the group
|
||||
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.title === "----") {
|
||||
$parent.append($("<div>").addClass("dropdown-divider"));
|
||||
// If the current item is a header, start a new group. This group will contain the
|
||||
// header and the next item that follows the header.
|
||||
if ("kind" in item && item.kind === "header") {
|
||||
if (multicolumn && !shouldResetGroup) {
|
||||
shouldStartNewGroup = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the next item is a separator, start a new group. This group will contain the
|
||||
// current item, the separator, and the next item after the separator.
|
||||
const nextItem = (index < items.length - 1) ? items[index + 1] : null;
|
||||
if (multicolumn && nextItem && "kind" in nextItem && nextItem.kind === "separator") {
|
||||
if (!shouldResetGroup) {
|
||||
shouldStartNewGroup = true;
|
||||
} else {
|
||||
shouldResetGroup = true; // Continue the current group
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new group to avoid column breaks before and after the seaparator / header.
|
||||
// This is a workaround for Firefox not supporting break-before / break-after: avoid
|
||||
// for columns.
|
||||
if (shouldStartNewGroup) {
|
||||
$group = $("<div class='dropdown-no-break'>");
|
||||
$parent.append($group);
|
||||
shouldStartNewGroup = false;
|
||||
}
|
||||
|
||||
if ("kind" in item && item.kind === "separator") {
|
||||
$group.append($("<div>").addClass("dropdown-divider"));
|
||||
shouldResetGroup = true; // End the group after the next item
|
||||
} else if ("kind" in item && item.kind === "header") {
|
||||
$group.append($("<h6>").addClass("dropdown-header").text(item.title));
|
||||
shouldResetGroup = true;
|
||||
} else {
|
||||
const $icon = $("<span>");
|
||||
|
||||
@@ -185,7 +230,23 @@ class ContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
if ("shortcut" in item && item.shortcut) {
|
||||
if ("keyboardShortcut" in item && item.keyboardShortcut) {
|
||||
const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts;
|
||||
if (shortcuts) {
|
||||
const allShortcuts: string[] = [];
|
||||
for (const effectiveShortcut of shortcuts) {
|
||||
allShortcuts.push(effectiveShortcut.split("+")
|
||||
.map(key => `<kbd>${key}</kbd>`)
|
||||
.join("+"));
|
||||
}
|
||||
|
||||
if (allShortcuts.length) {
|
||||
const container = $("<span>").addClass("keyboard-shortcut");
|
||||
container.append($(allShortcuts.join(",")));
|
||||
$link.append(container);
|
||||
}
|
||||
}
|
||||
} else if ("shortcut" in item && item.shortcut) {
|
||||
$link.append($("<kbd>").text(item.shortcut));
|
||||
}
|
||||
|
||||
@@ -241,16 +302,24 @@ class ContextMenu {
|
||||
$link.addClass("dropdown-toggle");
|
||||
|
||||
const $subMenu = $("<ul>").addClass("dropdown-menu");
|
||||
if (!this.isMobile && item.columns) {
|
||||
$subMenu.css("column-count", item.columns);
|
||||
const hasColumns = !!item.columns && item.columns > 1;
|
||||
if (!this.isMobile && hasColumns) {
|
||||
$subMenu.css("column-count", item.columns!);
|
||||
}
|
||||
|
||||
this.addItems($subMenu, item.items);
|
||||
this.addItems($subMenu, item.items, hasColumns);
|
||||
|
||||
$item.append($subMenu);
|
||||
}
|
||||
|
||||
$parent.append($item);
|
||||
$group.append($item);
|
||||
|
||||
// After adding a menu item, if the previous item was a separator or header,
|
||||
// reset the group so that the next item will be appended directly to the parent.
|
||||
if (shouldResetGroup) {
|
||||
$group = $parent;
|
||||
shouldResetGroup = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function setupContextMenu() {
|
||||
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
});
|
||||
|
||||
items.push({ title: `----` });
|
||||
items.push({ kind: "separator" });
|
||||
}
|
||||
|
||||
if (params.isEditable) {
|
||||
@@ -112,7 +112,7 @@ function setupContextMenu() {
|
||||
// Replace the placeholder with the real search keyword.
|
||||
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||
|
||||
items.push({ title: "----" });
|
||||
items.push({ kind: "separator" });
|
||||
|
||||
items.push({
|
||||
title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }),
|
||||
|
||||
@@ -2,8 +2,6 @@ import { t } from "../services/i18n.js";
|
||||
import utils from "../services/utils.js";
|
||||
import contextMenu from "./context_menu.js";
|
||||
import imageService from "../services/image.js";
|
||||
import mediaViewer from "../services/media_viewer.js";
|
||||
import type { MediaItem } from "../services/media_viewer.js";
|
||||
|
||||
const PROP_NAME = "imageContextMenuInstalled";
|
||||
|
||||
@@ -20,12 +18,6 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{
|
||||
title: "View in Lightbox",
|
||||
command: "viewInLightbox",
|
||||
uiIcon: "bx bx-expand",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
title: t("image_context_menu.copy_reference_to_clipboard"),
|
||||
command: "copyImageReferenceToClipboard",
|
||||
@@ -38,48 +30,7 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === "viewInLightbox") {
|
||||
const src = $image.attr("src");
|
||||
const alt = $image.attr("alt");
|
||||
const title = $image.attr("title");
|
||||
|
||||
if (!src) {
|
||||
console.error("Missing image source");
|
||||
return;
|
||||
}
|
||||
|
||||
const item: MediaItem = {
|
||||
src: src,
|
||||
alt: alt || "Image",
|
||||
title: title || alt,
|
||||
element: $image[0] as HTMLElement
|
||||
};
|
||||
|
||||
// Try to get actual dimensions
|
||||
const imgElement = $image[0] as HTMLImageElement;
|
||||
if (imgElement.naturalWidth && imgElement.naturalHeight) {
|
||||
item.width = imgElement.naturalWidth;
|
||||
item.height = imgElement.naturalHeight;
|
||||
}
|
||||
|
||||
mediaViewer.openSingle(item, {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
pinchToClose: true,
|
||||
closeOnScroll: false,
|
||||
closeOnVerticalDrag: true,
|
||||
wheelToZoom: true,
|
||||
getThumbBoundsFn: () => {
|
||||
// Get position for zoom animation
|
||||
const rect = imgElement.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
w: rect.width
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (command === "copyImageReferenceToClipboard") {
|
||||
if (command === "copyImageReferenceToClipboard") {
|
||||
imageService.copyImageReferenceToClipboard($image);
|
||||
} else if (command === "copyImageToClipboard") {
|
||||
try {
|
||||
|
||||
@@ -45,16 +45,16 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener<
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-script-launcher"), command: "addScriptLauncher", uiIcon: "bx bx-code-curly" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-custom-widget"), command: "addWidgetLauncher", uiIcon: "bx bx-customize" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-spacer"), command: "addSpacerLauncher", uiIcon: "bx bx-dots-horizontal" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: "----" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { kind: "separator" } : null,
|
||||
|
||||
isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
|
||||
isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
|
||||
isVisibleItem || isAvailableItem ? { title: "----" } : null,
|
||||
isVisibleItem || isAvailableItem ? { kind: "separator" } : null,
|
||||
|
||||
{ title: `${t("launcher_context_menu.duplicate-launcher")}`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem },
|
||||
{ title: `${t("launcher_context_menu.delete")}`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset }
|
||||
];
|
||||
|
||||
@@ -13,6 +13,8 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import utils from "../services/utils.js";
|
||||
import attributes from "../services/attributes.js";
|
||||
import { executeBulkActions } from "../services/bulk_action.js";
|
||||
|
||||
// TODO: Deduplicate once client/server is well split.
|
||||
interface ConvertToAttachmentResponse {
|
||||
@@ -61,6 +63,11 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
// the only exception is when the only selected note is the one that was right-clicked, then
|
||||
// it's clear what the user meant to do.
|
||||
const selNodes = this.treeWidget.getSelectedNodes();
|
||||
const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId));
|
||||
if (note && !selectedNotes.includes(note)) selectedNotes.push(note);
|
||||
const isArchived = selectedNotes.every(note => note.isArchived);
|
||||
const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived);
|
||||
|
||||
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
|
||||
|
||||
const notSearch = note?.type !== "search";
|
||||
@@ -69,27 +76,29 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
|
||||
|
||||
const items: (MenuItem<TreeCommandNames> | null)[] = [
|
||||
{ title: `${t("tree-context-menu.open-in-a-new-tab")}`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes },
|
||||
|
||||
isHoisted
|
||||
? null
|
||||
: {
|
||||
title: `${t("tree-context-menu.hoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`,
|
||||
title: `${t("tree-context-menu.hoist-note")}`,
|
||||
command: "toggleNoteHoisting",
|
||||
keyboardShortcut: "toggleNoteHoisting",
|
||||
uiIcon: "bx bxs-chevrons-up",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
!isHoisted || !isNotRoot
|
||||
? null
|
||||
: { title: `${t("tree-context-menu.unhoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
: { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.insert-note-after")}<kbd data-command="createNoteAfter"></kbd>`,
|
||||
title: t("tree-context-menu.insert-note-after"),
|
||||
command: "insertNoteAfter",
|
||||
keyboardShortcut: "createNoteAfter",
|
||||
uiIcon: "bx bx-plus",
|
||||
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
|
||||
enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp,
|
||||
@@ -97,21 +106,22 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
},
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.insert-child-note")}<kbd data-command="createNoteInto"></kbd>`,
|
||||
title: t("tree-context-menu.insert-child-note"),
|
||||
command: "insertChildNote",
|
||||
keyboardShortcut: "createNoteInto",
|
||||
uiIcon: "bx bx-plus",
|
||||
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
|
||||
enabled: notSearch && noSelectedNotes && notOptionsOrHelp,
|
||||
columns: 2
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: t("tree-context-menu.advanced"),
|
||||
@@ -120,48 +130,52 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
items: [
|
||||
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`,
|
||||
title: t("tree-context-menu.edit-branch-prefix"),
|
||||
command: "editBranchPrefix",
|
||||
keyboardShortcut: "editBranchPrefix",
|
||||
uiIcon: "bx bx-rename",
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{
|
||||
title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`,
|
||||
title: t("tree-context-menu.sort-by"),
|
||||
command: "sortChildNotes",
|
||||
keyboardShortcut: "sortChildNotes",
|
||||
uiIcon: "bx bx-sort-down",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
|
||||
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
|
||||
]
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.cut")} <kbd data-command="cutNotesToClipboard"></kbd>`,
|
||||
title: t("tree-context-menu.cut"),
|
||||
command: "cutNotesToClipboard",
|
||||
keyboardShortcut: "cutNotesToClipboard",
|
||||
uiIcon: "bx bx-cut",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch
|
||||
},
|
||||
|
||||
{ title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
|
||||
{ title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.paste-into")} <kbd data-command="pasteNotesFromClipboard"></kbd>`,
|
||||
title: t("tree-context-menu.paste-into"),
|
||||
command: "pasteNotesFromClipboard",
|
||||
keyboardShortcut: "pasteNotesFromClipboard",
|
||||
uiIcon: "bx bx-paste",
|
||||
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes
|
||||
},
|
||||
@@ -174,39 +188,71 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
},
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`,
|
||||
title: t("tree-context-menu.move-to"),
|
||||
command: "moveNotesTo",
|
||||
keyboardShortcut: "moveNotesTo",
|
||||
uiIcon: "bx bx-transfer",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch
|
||||
},
|
||||
|
||||
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
|
||||
{ title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.duplicate")} <kbd data-command="duplicateSubtree">`,
|
||||
title: t("tree-context-menu.duplicate"),
|
||||
command: "duplicateSubtree",
|
||||
keyboardShortcut: "duplicateSubtree",
|
||||
uiIcon: "bx bx-outline",
|
||||
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
|
||||
title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"),
|
||||
uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out",
|
||||
enabled: canToggleArchived,
|
||||
handler: () => {
|
||||
if (!selectedNotes.length) return;
|
||||
|
||||
if (selectedNotes.length == 1) {
|
||||
const note = selectedNotes[0];
|
||||
if (!isArchived) {
|
||||
attributes.addLabel(note.noteId, "archived");
|
||||
} else {
|
||||
attributes.removeOwnedLabelByName(note, "archived");
|
||||
}
|
||||
} else {
|
||||
const noteIds = selectedNotes.map(note => note.noteId);
|
||||
if (!isArchived) {
|
||||
executeBulkActions(noteIds, [{
|
||||
name: "addLabel", labelName: "archived"
|
||||
}]);
|
||||
} else {
|
||||
executeBulkActions(noteIds, [{
|
||||
name: "deleteLabel", labelName: "archived"
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("tree-context-menu.delete"),
|
||||
command: "deleteNotes",
|
||||
keyboardShortcut: "deleteNotes",
|
||||
uiIcon: "bx bx-trash destructive-action-icon",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
|
||||
|
||||
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
|
||||
|
||||
{ title: "----" },
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`,
|
||||
title: t("tree-context-menu.search-in-subtree"),
|
||||
command: "searchInSubtree",
|
||||
keyboardShortcut: "searchInSubtree",
|
||||
uiIcon: "bx bx-search",
|
||||
enabled: notSearch && noSelectedNotes
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import appContext from "./components/app_context.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import glob from "./services/glob.js";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
glob.setupGlobs();
|
||||
|
||||
@@ -2,6 +2,7 @@ import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
import { AttributeType } from "@triliumnext/commons";
|
||||
|
||||
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/attribute`, {
|
||||
@@ -25,6 +26,14 @@ async function removeAttributeById(noteId: string, attributeId: string) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
|
||||
export async function removeOwnedAttributesByNameOrType(note: FNote, type: AttributeType, name: string) {
|
||||
for (const attr of note.getOwnedAttributes()) {
|
||||
if (attr.type === type && attr.name === name) {
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attr.attributeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a label identified by its name from the given note, if it exists. Note that the label must be owned, i.e.
|
||||
* it will not remove inherited attributes.
|
||||
@@ -52,7 +61,7 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
|
||||
* @param value the value of the attribute to set.
|
||||
*/
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
|
||||
if (value) {
|
||||
if (value !== null && value !== undefined) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
} else {
|
||||
|
||||
@@ -210,7 +210,7 @@ function makeToast(id: string, message: string): ToastOptions {
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "deleteNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "deleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "undeleteNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "undeleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type FNote from "../entities/fnote.js";
|
||||
import toast from "./toast.js";
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
|
||||
const ACTION_GROUPS = [
|
||||
export const ACTION_GROUPS = [
|
||||
{
|
||||
title: t("bulk_actions.labels"),
|
||||
actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction]
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
/**
|
||||
* CKEditor PhotoSwipe Integration
|
||||
* Handles click-to-lightbox functionality for images in CKEditor content
|
||||
*/
|
||||
|
||||
import mediaViewer from './media_viewer.js';
|
||||
import galleryManager from './gallery_manager.js';
|
||||
import appContext from '../components/app_context.js';
|
||||
import type { MediaItem } from './media_viewer.js';
|
||||
import type { GalleryItem } from './gallery_manager.js';
|
||||
|
||||
/**
|
||||
* Configuration for CKEditor PhotoSwipe integration
|
||||
*/
|
||||
interface CKEditorPhotoSwipeConfig {
|
||||
enableGalleryMode?: boolean;
|
||||
showHints?: boolean;
|
||||
hintDelay?: number;
|
||||
excludeSelector?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration manager for CKEditor and PhotoSwipe
|
||||
*/
|
||||
class CKEditorPhotoSwipeIntegration {
|
||||
private static instance: CKEditorPhotoSwipeIntegration;
|
||||
private config: Required<CKEditorPhotoSwipeConfig>;
|
||||
private observers: Map<HTMLElement, MutationObserver> = new Map();
|
||||
private processedImages: WeakSet<HTMLImageElement> = new WeakSet();
|
||||
private containerGalleries: Map<HTMLElement, GalleryItem[]> = new Map();
|
||||
private hintPool: HTMLElement[] = [];
|
||||
private activeHints: Map<string, HTMLElement> = new Map();
|
||||
private hintTimeouts: Map<string, number> = new Map();
|
||||
|
||||
private constructor() {
|
||||
this.config = {
|
||||
enableGalleryMode: true,
|
||||
showHints: true,
|
||||
hintDelay: 2000,
|
||||
excludeSelector: '.no-lightbox, .cke_widget_element'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): CKEditorPhotoSwipeIntegration {
|
||||
if (!CKEditorPhotoSwipeIntegration.instance) {
|
||||
CKEditorPhotoSwipeIntegration.instance = new CKEditorPhotoSwipeIntegration();
|
||||
}
|
||||
return CKEditorPhotoSwipeIntegration.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup integration for a CKEditor content container
|
||||
*/
|
||||
setupContainer(container: HTMLElement | JQuery<HTMLElement>, config?: Partial<CKEditorPhotoSwipeConfig>): void {
|
||||
const element = container instanceof $ ? container[0] : container;
|
||||
if (!element) return;
|
||||
|
||||
// Merge configuration
|
||||
if (config) {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
// Process existing images
|
||||
this.processImages(element);
|
||||
|
||||
// Setup mutation observer for dynamically added images
|
||||
this.observeContainer(element);
|
||||
|
||||
// Setup gallery if enabled
|
||||
if (this.config.enableGalleryMode) {
|
||||
this.setupGalleryMode(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all images in a container
|
||||
*/
|
||||
private processImages(container: HTMLElement): void {
|
||||
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
|
||||
|
||||
images.forEach(img => {
|
||||
if (!this.processedImages.has(img)) {
|
||||
this.setupImageLightbox(img);
|
||||
this.processedImages.add(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lightbox for a single image
|
||||
*/
|
||||
private setupImageLightbox(img: HTMLImageElement): void {
|
||||
// Skip if already processed or is a CKEditor widget element
|
||||
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make image clickable and mark it as PhotoSwipe-enabled
|
||||
img.style.cursor = 'zoom-in';
|
||||
img.style.transition = 'opacity 0.2s';
|
||||
img.classList.add('photoswipe-enabled');
|
||||
img.setAttribute('data-photoswipe', 'true');
|
||||
|
||||
// Store event handlers for cleanup
|
||||
const mouseEnterHandler = () => {
|
||||
img.style.opacity = '0.9';
|
||||
if (this.config.showHints) {
|
||||
this.showHint(img);
|
||||
}
|
||||
};
|
||||
|
||||
const mouseLeaveHandler = () => {
|
||||
img.style.opacity = '1';
|
||||
this.hideHint(img);
|
||||
};
|
||||
|
||||
// Add hover effect with cleanup tracking
|
||||
img.addEventListener('mouseenter', mouseEnterHandler);
|
||||
img.addEventListener('mouseleave', mouseLeaveHandler);
|
||||
|
||||
// Store handlers for cleanup
|
||||
(img as any)._photoswipeHandlers = { mouseEnterHandler, mouseLeaveHandler };
|
||||
|
||||
// Add double-click handler to prevent default navigation behavior
|
||||
const dblClickHandler = (e: MouseEvent) => {
|
||||
// Only prevent double-click in specific contexts to avoid breaking other features
|
||||
if (img.closest('.attachment-detail-wrapper') ||
|
||||
img.closest('.note-detail-editable-text') ||
|
||||
img.closest('.note-detail-readonly-text')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// Trigger the same behavior as single click (open lightbox)
|
||||
img.click();
|
||||
}
|
||||
};
|
||||
|
||||
img.addEventListener('dblclick', dblClickHandler, true); // Use capture phase to ensure we get it first
|
||||
(img as any)._photoswipeHandlers.dblClickHandler = dblClickHandler;
|
||||
|
||||
// Add click handler
|
||||
img.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if we should open as gallery
|
||||
const container = img.closest('.note-detail-editable-text, .note-detail-readonly-text');
|
||||
if (container && this.config.enableGalleryMode) {
|
||||
const gallery = this.containerGalleries.get(container as HTMLElement);
|
||||
if (gallery && gallery.length > 1) {
|
||||
// Find index of clicked image
|
||||
const index = gallery.findIndex(item => {
|
||||
const itemElement = document.querySelector(`img[src="${item.src}"]`);
|
||||
return itemElement === img;
|
||||
});
|
||||
|
||||
galleryManager.openGallery(gallery, index >= 0 ? index : 0, {
|
||||
showThumbnails: true,
|
||||
showCounter: true,
|
||||
enableKeyboardNav: true,
|
||||
loop: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Open single image
|
||||
this.openSingleImage(img);
|
||||
});
|
||||
|
||||
// Add keyboard support
|
||||
img.setAttribute('tabindex', '0');
|
||||
img.setAttribute('role', 'button');
|
||||
img.setAttribute('aria-label', 'Click to view in lightbox');
|
||||
|
||||
img.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
img.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a single image in lightbox
|
||||
*/
|
||||
private openSingleImage(img: HTMLImageElement): void {
|
||||
const item: MediaItem = {
|
||||
src: img.src,
|
||||
alt: img.alt || 'Image',
|
||||
title: img.title || img.alt,
|
||||
element: img,
|
||||
width: img.naturalWidth || undefined,
|
||||
height: img.naturalHeight || undefined
|
||||
};
|
||||
|
||||
mediaViewer.openSingle(item, {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
pinchToClose: true,
|
||||
closeOnScroll: false,
|
||||
closeOnVerticalDrag: true,
|
||||
wheelToZoom: true,
|
||||
getThumbBoundsFn: () => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
w: rect.width
|
||||
};
|
||||
}
|
||||
}, {
|
||||
onClose: () => {
|
||||
// Check if we're in attachment detail view and need to reset viewScope
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext?.viewScope?.viewMode === 'attachments') {
|
||||
// Get the note ID from the image source
|
||||
const attachmentMatch = img.src.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//);
|
||||
if (attachmentMatch) {
|
||||
const currentAttachmentId = activeContext.viewScope.attachmentId;
|
||||
if (currentAttachmentId === attachmentMatch[1]) {
|
||||
// Actually reset the viewScope instead of just logging
|
||||
try {
|
||||
if (activeContext.note) {
|
||||
activeContext.setNote(activeContext.note.noteId, {
|
||||
viewScope: { viewMode: 'default' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset viewScope after PhotoSwipe close:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Restore focus to the image
|
||||
img.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup gallery mode for a container
|
||||
*/
|
||||
private setupGalleryMode(container: HTMLElement): void {
|
||||
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
|
||||
if (images.length <= 1) return;
|
||||
|
||||
const galleryItems: GalleryItem[] = [];
|
||||
|
||||
images.forEach((img, index) => {
|
||||
// Skip CKEditor widget elements
|
||||
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: GalleryItem = {
|
||||
src: img.src,
|
||||
alt: img.alt || `Image ${index + 1}`,
|
||||
title: img.title || img.alt,
|
||||
element: img,
|
||||
index: index,
|
||||
width: img.naturalWidth || undefined,
|
||||
height: img.naturalHeight || undefined
|
||||
};
|
||||
|
||||
// Check for caption
|
||||
const figure = img.closest('figure');
|
||||
if (figure) {
|
||||
const caption = figure.querySelector('figcaption');
|
||||
if (caption) {
|
||||
item.caption = caption.textContent || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
galleryItems.push(item);
|
||||
});
|
||||
|
||||
if (galleryItems.length > 0) {
|
||||
this.containerGalleries.set(container, galleryItems);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe container for dynamic changes
|
||||
*/
|
||||
private observeContainer(container: HTMLElement): void {
|
||||
// Disconnect existing observer if any
|
||||
const existingObserver = this.observers.get(container);
|
||||
if (existingObserver) {
|
||||
existingObserver.disconnect();
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let hasNewImages = false;
|
||||
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as HTMLElement;
|
||||
if (element.tagName === 'IMG') {
|
||||
hasNewImages = true;
|
||||
} else if (element.querySelector('img')) {
|
||||
hasNewImages = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNewImages) {
|
||||
// Process new images
|
||||
this.processImages(container);
|
||||
|
||||
// Update gallery if enabled
|
||||
if (this.config.enableGalleryMode) {
|
||||
this.setupGalleryMode(container);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
this.observers.set(container, observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a hint element from the pool
|
||||
*/
|
||||
private getHintFromPool(): HTMLElement {
|
||||
let hint = this.hintPool.pop();
|
||||
if (!hint) {
|
||||
hint = document.createElement('div');
|
||||
hint.className = 'ckeditor-image-hint';
|
||||
hint.textContent = 'Click to view in lightbox';
|
||||
hint.style.cssText = `
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
display: none;
|
||||
`;
|
||||
}
|
||||
return hint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return hint to pool
|
||||
*/
|
||||
private returnHintToPool(hint: HTMLElement): void {
|
||||
hint.style.opacity = '0';
|
||||
hint.style.display = 'none';
|
||||
if (this.hintPool.length < 10) { // Keep max 10 hints in pool
|
||||
this.hintPool.push(hint);
|
||||
} else {
|
||||
hint.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show hint for an image
|
||||
*/
|
||||
private showHint(img: HTMLImageElement): void {
|
||||
// Check if hint already exists
|
||||
const imgId = img.dataset.imgId || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
if (!img.dataset.imgId) {
|
||||
img.dataset.imgId = imgId;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
const existingTimeout = this.hintTimeouts.get(imgId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
this.hintTimeouts.delete(imgId);
|
||||
}
|
||||
|
||||
let hint = this.activeHints.get(imgId);
|
||||
if (hint) {
|
||||
hint.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get hint from pool
|
||||
hint = this.getHintFromPool();
|
||||
this.activeHints.set(imgId, hint);
|
||||
|
||||
// Position and show hint
|
||||
if (!hint.parentElement) {
|
||||
document.body.appendChild(hint);
|
||||
}
|
||||
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
hint.style.display = 'block';
|
||||
hint.style.left = `${imgRect.left + (imgRect.width - hint.offsetWidth) / 2}px`;
|
||||
hint.style.top = `${imgRect.top - hint.offsetHeight - 5}px`;
|
||||
|
||||
// Show hint
|
||||
requestAnimationFrame(() => {
|
||||
hint.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Auto-hide after delay
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.hideHint(img);
|
||||
}, this.config.hintDelay);
|
||||
this.hintTimeouts.set(imgId, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide hint for an image
|
||||
*/
|
||||
private hideHint(img: HTMLImageElement): void {
|
||||
const imgId = img.dataset.imgId;
|
||||
if (!imgId) return;
|
||||
|
||||
// Clear timeout
|
||||
const timeout = this.hintTimeouts.get(imgId);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.hintTimeouts.delete(imgId);
|
||||
}
|
||||
|
||||
const hint = this.activeHints.get(imgId);
|
||||
if (hint) {
|
||||
hint.style.opacity = '0';
|
||||
this.activeHints.delete(imgId);
|
||||
|
||||
setTimeout(() => {
|
||||
this.returnHintToPool(hint);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup integration for a container
|
||||
*/
|
||||
cleanupContainer(container: HTMLElement | JQuery<HTMLElement>): void {
|
||||
const element = container instanceof $ ? container[0] : container;
|
||||
if (!element) return;
|
||||
|
||||
// Disconnect observer
|
||||
const observer = this.observers.get(element);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
this.observers.delete(element);
|
||||
}
|
||||
|
||||
// Clear gallery
|
||||
this.containerGalleries.delete(element);
|
||||
|
||||
// Remove event handlers and hints
|
||||
const images = element.querySelectorAll<HTMLImageElement>('img');
|
||||
images.forEach(img => {
|
||||
this.hideHint(img);
|
||||
|
||||
// Remove event handlers
|
||||
const handlers = (img as any)._photoswipeHandlers;
|
||||
if (handlers) {
|
||||
img.removeEventListener('mouseenter', handlers.mouseEnterHandler);
|
||||
img.removeEventListener('mouseleave', handlers.mouseLeaveHandler);
|
||||
if (handlers.dblClickHandler) {
|
||||
img.removeEventListener('dblclick', handlers.dblClickHandler, true);
|
||||
}
|
||||
delete (img as any)._photoswipeHandlers;
|
||||
}
|
||||
|
||||
// Mark as unprocessed
|
||||
this.processedImages.delete(img);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<CKEditorPhotoSwipeConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all integrations
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Disconnect all observers
|
||||
this.observers.forEach(observer => observer.disconnect());
|
||||
this.observers.clear();
|
||||
|
||||
// Clear all galleries
|
||||
this.containerGalleries.clear();
|
||||
|
||||
// Clear all hints
|
||||
this.activeHints.forEach(hint => hint.remove());
|
||||
this.activeHints.clear();
|
||||
|
||||
// Clear all timeouts
|
||||
this.hintTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.hintTimeouts.clear();
|
||||
|
||||
// Clear hint pool
|
||||
this.hintPool.forEach(hint => hint.remove());
|
||||
this.hintPool = [];
|
||||
|
||||
// Clear processed images
|
||||
this.processedImages = new WeakSet();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default CKEditorPhotoSwipeIntegration.getInstance();
|
||||
@@ -256,8 +256,19 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
|
||||
</button>
|
||||
`);
|
||||
|
||||
$downloadButton.on("click", () => openService.downloadFileNote(entity.noteId));
|
||||
$openButton.on("click", () => openService.openNoteExternally(entity.noteId, entity.mime));
|
||||
$downloadButton.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openService.downloadFileNote(entity.noteId)
|
||||
});
|
||||
$openButton.on("click", async (e) => {
|
||||
const iconEl = $openButton.find("> .bx");
|
||||
iconEl.removeClass("bx bx-link-external");
|
||||
iconEl.addClass("bx bx-loader spin");
|
||||
e.stopPropagation();
|
||||
await openService.openNoteExternally(entity.noteId, entity.mime)
|
||||
iconEl.removeClass("bx bx-loader spin");
|
||||
iconEl.addClass("bx bx-link-external");
|
||||
});
|
||||
// open doesn't work for protected notes since it works through a browser which isn't in protected session
|
||||
$openButton.toggle(!entity.isProtected);
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) {
|
||||
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
|
||||
}
|
||||
|
||||
async function prompt(props: PromptDialogOptions) {
|
||||
export async function prompt(props: PromptDialogOptions) {
|
||||
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,6 @@ function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
const basePath = window.glob.isDev ? new URL(window.glob.assetPath).pathname : window.glob.assetPath;
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { OpenedFileUpdateStatus } from "@triliumnext/commons";
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface Message {
|
||||
type: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastModifiedMs: number;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
const fileModificationStatus: Record<string, Record<string, Message>> = {
|
||||
const fileModificationStatus: Record<string, Record<string, OpenedFileUpdateStatus>> = {
|
||||
notes: {},
|
||||
attachments: {}
|
||||
};
|
||||
@@ -39,7 +31,7 @@ function ignoreModification(entityType: string, entityId: string) {
|
||||
delete fileModificationStatus[entityType][entityId];
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message: Message) => {
|
||||
ws.subscribeToMessages(async message => {
|
||||
if (message.type !== "openedFileUpdated") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
const loadResults = new LoadResults(entityChanges);
|
||||
@@ -30,13 +31,14 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
continue; // only noise
|
||||
}
|
||||
|
||||
options.set(attributeEntity.name, attributeEntity.value);
|
||||
|
||||
loadResults.addOption(attributeEntity.name);
|
||||
options.set(attributeEntity.name as OptionNames, attributeEntity.value);
|
||||
loadResults.addOption(attributeEntity.name as OptionNames);
|
||||
} else if (ec.entityName === "attachments") {
|
||||
processAttachment(loadResults, ec);
|
||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
||||
} else if (ec.entityName === "blobs") {
|
||||
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||
} else if (ec.entityName === "etapi_tokens") {
|
||||
loadResults.hasEtapiTokenChanges = true;
|
||||
} else {
|
||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||
}
|
||||
@@ -77,9 +79,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
noteAttributeCache.invalidate();
|
||||
}
|
||||
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const appContext = (await import("../components/app_context.js")).default as any;
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import dayjs from "dayjs";
|
||||
import type NoteContext from "../components/note_context.js";
|
||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import type Component from "../components/component.js";
|
||||
import { formatLogMessage } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* A whole number
|
||||
@@ -455,7 +456,7 @@ export interface Api {
|
||||
/**
|
||||
* Log given message to the log pane in UI
|
||||
*/
|
||||
log(message: string): void;
|
||||
log(message: string | object): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -696,7 +697,7 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
|
||||
this.log = (message) => {
|
||||
const { noteId } = this.startNote;
|
||||
|
||||
message = `${utils.now()}: ${message}`;
|
||||
message = `${utils.now()}: ${formatLogMessage(message)}`;
|
||||
|
||||
console.log(`Script ${noteId}: ${message}`);
|
||||
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
/**
|
||||
* Tests for Gallery Manager
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import galleryManager from './gallery_manager';
|
||||
import mediaViewer from './media_viewer';
|
||||
import type { GalleryItem, GalleryConfig } from './gallery_manager';
|
||||
import type { MediaViewerCallbacks } from './media_viewer';
|
||||
|
||||
// Mock media viewer
|
||||
vi.mock('./media_viewer', () => ({
|
||||
default: {
|
||||
open: vi.fn(),
|
||||
openSingle: vi.fn(),
|
||||
close: vi.fn(),
|
||||
next: vi.fn(),
|
||||
prev: vi.fn(),
|
||||
goTo: vi.fn(),
|
||||
getCurrentIndex: vi.fn(() => 0),
|
||||
isOpen: vi.fn(() => false),
|
||||
getImageDimensions: vi.fn(() => Promise.resolve({ width: 800, height: 600 }))
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock froca
|
||||
vi.mock('./froca', () => ({
|
||||
default: {
|
||||
getNoteComplement: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock('./utils', () => ({
|
||||
default: {
|
||||
createImageSrcUrl: vi.fn((note: any) => `/api/images/${note.noteId}`),
|
||||
randomString: vi.fn(() => 'test123')
|
||||
}
|
||||
}));
|
||||
|
||||
describe('GalleryManager', () => {
|
||||
let mockItems: GalleryItem[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock gallery items
|
||||
mockItems = [
|
||||
{
|
||||
src: '/api/images/note1/image1.jpg',
|
||||
alt: 'Image 1',
|
||||
title: 'First Image',
|
||||
noteId: 'note1',
|
||||
index: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
},
|
||||
{
|
||||
src: '/api/images/note1/image2.jpg',
|
||||
alt: 'Image 2',
|
||||
title: 'Second Image',
|
||||
noteId: 'note1',
|
||||
index: 1,
|
||||
width: 1024,
|
||||
height: 768
|
||||
},
|
||||
{
|
||||
src: '/api/images/note1/image3.jpg',
|
||||
alt: 'Image 3',
|
||||
title: 'Third Image',
|
||||
noteId: 'note1',
|
||||
index: 2,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
];
|
||||
|
||||
// Setup DOM
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
galleryManager.cleanup();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Gallery Creation', () => {
|
||||
it('should create gallery from container with images', async () => {
|
||||
// Create container with images
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
|
||||
<img src="/api/images/note1/image2.jpg" alt="Image 2" />
|
||||
<img src="/api/images/note1/image3.jpg" alt="Image 3" />
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create gallery from container
|
||||
const items = await galleryManager.createGalleryFromContainer(container);
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].src).toBe('/api/images/note1/image1.jpg');
|
||||
expect(items[0].alt).toBe('Image 1');
|
||||
expect(items[0].index).toBe(0);
|
||||
});
|
||||
|
||||
it('should extract captions from figure elements', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<figure>
|
||||
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
|
||||
<figcaption>This is a caption</figcaption>
|
||||
</figure>
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const items = await galleryManager.createGalleryFromContainer(container);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].caption).toBe('This is a caption');
|
||||
});
|
||||
|
||||
it('should handle images without dimensions', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `<img src="/api/images/note1/image1.jpg" alt="Image 1" />`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const items = await galleryManager.createGalleryFromContainer(container);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].width).toBe(800); // From mocked getImageDimensions
|
||||
expect(items[0].height).toBe(600);
|
||||
expect(mediaViewer.getImageDimensions).toHaveBeenCalledWith('/api/images/note1/image1.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery Opening', () => {
|
||||
it('should open gallery with multiple items', () => {
|
||||
const callbacks: MediaViewerCallbacks = {
|
||||
onOpen: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onChange: vi.fn()
|
||||
};
|
||||
|
||||
galleryManager.openGallery(mockItems, 0, {}, callbacks);
|
||||
|
||||
expect(mediaViewer.open).toHaveBeenCalledWith(
|
||||
mockItems,
|
||||
0,
|
||||
expect.objectContaining({
|
||||
loop: true,
|
||||
allowPanToNext: true,
|
||||
preload: [2, 2]
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onOpen: expect.any(Function),
|
||||
onClose: expect.any(Function),
|
||||
onChange: expect.any(Function)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty items array', () => {
|
||||
galleryManager.openGallery([], 0);
|
||||
expect(mediaViewer.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom configuration', () => {
|
||||
const config: GalleryConfig = {
|
||||
showThumbnails: false,
|
||||
autoPlay: true,
|
||||
slideInterval: 5000,
|
||||
loop: false
|
||||
};
|
||||
|
||||
galleryManager.openGallery(mockItems, 0, config);
|
||||
|
||||
expect(mediaViewer.open).toHaveBeenCalledWith(
|
||||
mockItems,
|
||||
0,
|
||||
expect.objectContaining({
|
||||
loop: false
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery Navigation', () => {
|
||||
beforeEach(() => {
|
||||
// Open a gallery first
|
||||
galleryManager.openGallery(mockItems, 0);
|
||||
});
|
||||
|
||||
it('should navigate to next slide', () => {
|
||||
galleryManager.nextSlide();
|
||||
expect(mediaViewer.next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate to previous slide', () => {
|
||||
galleryManager.previousSlide();
|
||||
expect(mediaViewer.prev).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should go to specific slide', () => {
|
||||
galleryManager.goToSlide(2);
|
||||
expect(mediaViewer.goTo).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should not navigate to invalid slide index', () => {
|
||||
const state = galleryManager.getGalleryState();
|
||||
if (state) {
|
||||
// Try to go to invalid index
|
||||
galleryManager.goToSlide(-1);
|
||||
expect(mediaViewer.goTo).not.toHaveBeenCalled();
|
||||
|
||||
galleryManager.goToSlide(10);
|
||||
expect(mediaViewer.goTo).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slideshow Functionality', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
galleryManager.openGallery(mockItems, 0, { autoPlay: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should start slideshow', () => {
|
||||
const state = galleryManager.getGalleryState();
|
||||
expect(state?.isPlaying).toBe(false);
|
||||
|
||||
galleryManager.startSlideshow();
|
||||
|
||||
const updatedState = galleryManager.getGalleryState();
|
||||
expect(updatedState?.isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop slideshow', () => {
|
||||
galleryManager.startSlideshow();
|
||||
galleryManager.stopSlideshow();
|
||||
|
||||
const state = galleryManager.getGalleryState();
|
||||
expect(state?.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle slideshow', () => {
|
||||
const initialState = galleryManager.getGalleryState();
|
||||
expect(initialState?.isPlaying).toBe(false);
|
||||
|
||||
galleryManager.toggleSlideshow();
|
||||
expect(galleryManager.getGalleryState()?.isPlaying).toBe(true);
|
||||
|
||||
galleryManager.toggleSlideshow();
|
||||
expect(galleryManager.getGalleryState()?.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('should advance slides automatically in slideshow', () => {
|
||||
galleryManager.startSlideshow();
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(4000); // Default interval
|
||||
|
||||
expect(mediaViewer.goTo).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should update slideshow interval', () => {
|
||||
galleryManager.startSlideshow();
|
||||
galleryManager.updateSlideshowInterval(5000);
|
||||
|
||||
const state = galleryManager.getGalleryState();
|
||||
expect(state?.config.slideInterval).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery State', () => {
|
||||
it('should track gallery state', () => {
|
||||
expect(galleryManager.getGalleryState()).toBeNull();
|
||||
|
||||
galleryManager.openGallery(mockItems, 1);
|
||||
|
||||
const state = galleryManager.getGalleryState();
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.items).toEqual(mockItems);
|
||||
expect(state?.currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should check if gallery is open', () => {
|
||||
expect(galleryManager.isGalleryOpen()).toBe(false);
|
||||
|
||||
vi.mocked(mediaViewer.isOpen).mockReturnValue(true);
|
||||
galleryManager.openGallery(mockItems, 0);
|
||||
|
||||
expect(galleryManager.isGalleryOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery Cleanup', () => {
|
||||
it('should close gallery on cleanup', () => {
|
||||
galleryManager.openGallery(mockItems, 0);
|
||||
galleryManager.cleanup();
|
||||
|
||||
expect(mediaViewer.close).toHaveBeenCalled();
|
||||
expect(galleryManager.getGalleryState()).toBeNull();
|
||||
});
|
||||
|
||||
it('should stop slideshow on close', () => {
|
||||
galleryManager.openGallery(mockItems, 0, { autoPlay: true });
|
||||
|
||||
const state = galleryManager.getGalleryState();
|
||||
expect(state?.isPlaying).toBe(true);
|
||||
|
||||
galleryManager.closeGallery();
|
||||
expect(mediaViewer.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI Enhancements', () => {
|
||||
beforeEach(() => {
|
||||
// Create PhotoSwipe container mock
|
||||
const pswpElement = document.createElement('div');
|
||||
pswpElement.className = 'pswp';
|
||||
document.body.appendChild(pswpElement);
|
||||
});
|
||||
|
||||
it('should add thumbnail strip when enabled', (done) => {
|
||||
galleryManager.openGallery(mockItems, 0, { showThumbnails: true });
|
||||
|
||||
// Wait for UI setup
|
||||
setTimeout(() => {
|
||||
const thumbnailStrip = document.querySelector('.gallery-thumbnail-strip');
|
||||
expect(thumbnailStrip).toBeTruthy();
|
||||
|
||||
const thumbnails = document.querySelectorAll('.gallery-thumbnail');
|
||||
expect(thumbnails).toHaveLength(3);
|
||||
|
||||
done();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
it('should add slideshow controls', (done) => {
|
||||
galleryManager.openGallery(mockItems, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
const controls = document.querySelector('.gallery-slideshow-controls');
|
||||
expect(controls).toBeTruthy();
|
||||
|
||||
const playPauseBtn = document.querySelector('.slideshow-play-pause');
|
||||
expect(playPauseBtn).toBeTruthy();
|
||||
|
||||
done();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
it('should add image counter when enabled', (done) => {
|
||||
galleryManager.openGallery(mockItems, 0, { showCounter: true });
|
||||
|
||||
setTimeout(() => {
|
||||
const counter = document.querySelector('.gallery-counter');
|
||||
expect(counter).toBeTruthy();
|
||||
expect(counter?.textContent).toContain('1');
|
||||
expect(counter?.textContent).toContain('3');
|
||||
|
||||
done();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
it('should add keyboard hints', (done) => {
|
||||
galleryManager.openGallery(mockItems, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
const hints = document.querySelector('.gallery-keyboard-hints');
|
||||
expect(hints).toBeTruthy();
|
||||
expect(hints?.textContent).toContain('Navigate');
|
||||
expect(hints?.textContent).toContain('ESC');
|
||||
|
||||
done();
|
||||
}, 150);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,987 +0,0 @@
|
||||
/**
|
||||
* Gallery Manager for PhotoSwipe integration in Trilium Notes
|
||||
* Handles multi-image galleries, slideshow mode, and navigation features
|
||||
*/
|
||||
|
||||
import mediaViewer, { MediaItem, MediaViewerCallbacks, MediaViewerConfig } from './media_viewer.js';
|
||||
import utils from './utils.js';
|
||||
import froca from './froca.js';
|
||||
import type FNote from '../entities/fnote.js';
|
||||
|
||||
/**
|
||||
* Gallery configuration options
|
||||
*/
|
||||
export interface GalleryConfig {
|
||||
showThumbnails?: boolean;
|
||||
thumbnailHeight?: number;
|
||||
autoPlay?: boolean;
|
||||
slideInterval?: number; // in milliseconds
|
||||
showCounter?: boolean;
|
||||
enableKeyboardNav?: boolean;
|
||||
enableSwipeGestures?: boolean;
|
||||
preloadCount?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery item with additional metadata
|
||||
*/
|
||||
export interface GalleryItem extends MediaItem {
|
||||
noteId?: string;
|
||||
attachmentId?: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery state management
|
||||
*/
|
||||
interface GalleryState {
|
||||
items: GalleryItem[];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
slideshowTimer?: number;
|
||||
config: Required<GalleryConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GalleryManager handles multi-image galleries with slideshow and navigation features
|
||||
*/
|
||||
class GalleryManager {
|
||||
private static instance: GalleryManager;
|
||||
private currentGallery: GalleryState | null = null;
|
||||
private defaultConfig: Required<GalleryConfig> = {
|
||||
showThumbnails: true,
|
||||
thumbnailHeight: 80,
|
||||
autoPlay: false,
|
||||
slideInterval: 4000,
|
||||
showCounter: true,
|
||||
enableKeyboardNav: true,
|
||||
enableSwipeGestures: true,
|
||||
preloadCount: 2,
|
||||
loop: true
|
||||
};
|
||||
|
||||
private slideshowCallbacks: Set<() => void> = new Set();
|
||||
private $thumbnailStrip?: JQuery<HTMLElement>;
|
||||
private $slideshowControls?: JQuery<HTMLElement>;
|
||||
|
||||
// Track all dynamically created elements for proper cleanup
|
||||
private createdElements: Map<string, HTMLElement | JQuery<HTMLElement>> = new Map();
|
||||
private setupTimeout?: number;
|
||||
|
||||
private constructor() {
|
||||
// Cleanup on window unload
|
||||
window.addEventListener('beforeunload', () => this.cleanup());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): GalleryManager {
|
||||
if (!GalleryManager.instance) {
|
||||
GalleryManager.instance = new GalleryManager();
|
||||
}
|
||||
return GalleryManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gallery from images in a note's content
|
||||
*/
|
||||
async createGalleryFromNote(note: FNote, config?: GalleryConfig): Promise<GalleryItem[]> {
|
||||
const items: GalleryItem[] = [];
|
||||
|
||||
try {
|
||||
// Parse note content to find images
|
||||
const parser = new DOMParser();
|
||||
const content = await note.getContent();
|
||||
const doc = parser.parseFromString(content || '', 'text/html');
|
||||
const images = doc.querySelectorAll('img');
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i];
|
||||
const src = img.getAttribute('src');
|
||||
|
||||
if (!src) continue;
|
||||
|
||||
// Convert relative URLs to absolute
|
||||
const absoluteSrc = this.resolveImageSrc(src, note.noteId);
|
||||
|
||||
const item: GalleryItem = {
|
||||
src: absoluteSrc,
|
||||
alt: img.getAttribute('alt') || `Image ${i + 1} from ${note.title}`,
|
||||
title: img.getAttribute('title') || img.getAttribute('alt') || undefined,
|
||||
caption: img.getAttribute('data-caption') || undefined,
|
||||
noteId: note.noteId,
|
||||
index: i,
|
||||
width: parseInt(img.getAttribute('width') || '0') || undefined,
|
||||
height: parseInt(img.getAttribute('height') || '0') || undefined
|
||||
};
|
||||
|
||||
// Try to get thumbnail from data attribute or create one
|
||||
const thumbnailSrc = img.getAttribute('data-thumbnail');
|
||||
if (thumbnailSrc) {
|
||||
item.msrc = this.resolveImageSrc(thumbnailSrc, note.noteId);
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
// Also check for image attachments
|
||||
const attachmentItems = await this.getAttachmentImages(note);
|
||||
items.push(...attachmentItems);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create gallery from note:', error);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image attachments from a note
|
||||
*/
|
||||
private async getAttachmentImages(note: FNote): Promise<GalleryItem[]> {
|
||||
const items: GalleryItem[] = [];
|
||||
|
||||
try {
|
||||
// Get child notes that are images
|
||||
const childNotes = await note.getChildNotes();
|
||||
|
||||
for (const childNote of childNotes) {
|
||||
if (childNote.type === 'image') {
|
||||
const item: GalleryItem = {
|
||||
src: utils.createImageSrcUrl(childNote),
|
||||
alt: childNote.title,
|
||||
title: childNote.title,
|
||||
noteId: childNote.noteId,
|
||||
index: items.length
|
||||
};
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get attachment images:', error);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gallery from a container element with images
|
||||
*/
|
||||
async createGalleryFromContainer(
|
||||
container: HTMLElement | JQuery<HTMLElement>,
|
||||
selector: string = 'img',
|
||||
config?: GalleryConfig
|
||||
): Promise<GalleryItem[]> {
|
||||
const $container = $(container);
|
||||
const images = $container.find(selector);
|
||||
const items: GalleryItem[] = [];
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i] as HTMLImageElement;
|
||||
|
||||
const item: GalleryItem = {
|
||||
src: img.src,
|
||||
alt: img.alt || `Image ${i + 1}`,
|
||||
title: img.title || img.alt || undefined,
|
||||
element: img,
|
||||
index: i,
|
||||
width: img.naturalWidth || undefined,
|
||||
height: img.naturalHeight || undefined
|
||||
};
|
||||
|
||||
// Try to extract caption from nearby elements
|
||||
const $img = $(img);
|
||||
const $figure = $img.closest('figure');
|
||||
if ($figure.length) {
|
||||
const $caption = $figure.find('figcaption');
|
||||
if ($caption.length) {
|
||||
item.caption = $caption.text();
|
||||
}
|
||||
}
|
||||
|
||||
// Check for data attributes
|
||||
item.noteId = $img.data('note-id');
|
||||
item.attachmentId = $img.data('attachment-id');
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open gallery with specified items
|
||||
*/
|
||||
openGallery(
|
||||
items: GalleryItem[],
|
||||
startIndex: number = 0,
|
||||
config?: GalleryConfig,
|
||||
callbacks?: MediaViewerCallbacks
|
||||
): void {
|
||||
if (!items || items.length === 0) {
|
||||
console.warn('No items provided to gallery');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing gallery
|
||||
this.closeGallery();
|
||||
|
||||
// Merge configuration
|
||||
const finalConfig = { ...this.defaultConfig, ...config };
|
||||
|
||||
// Initialize gallery state
|
||||
this.currentGallery = {
|
||||
items,
|
||||
currentIndex: startIndex,
|
||||
isPlaying: finalConfig.autoPlay,
|
||||
config: finalConfig
|
||||
};
|
||||
|
||||
// Enhanced PhotoSwipe configuration for gallery
|
||||
const photoSwipeConfig: Partial<MediaViewerConfig> = {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
allowPanToNext: true,
|
||||
spacing: 0.12,
|
||||
loop: finalConfig.loop,
|
||||
arrowKeys: finalConfig.enableKeyboardNav,
|
||||
pinchToClose: finalConfig.enableSwipeGestures,
|
||||
closeOnVerticalDrag: finalConfig.enableSwipeGestures,
|
||||
preload: [finalConfig.preloadCount, finalConfig.preloadCount],
|
||||
wheelToZoom: true,
|
||||
// Enable mobile and accessibility enhancements
|
||||
mobileA11y: {
|
||||
touch: {
|
||||
hapticFeedback: true,
|
||||
multiTouchEnabled: true
|
||||
},
|
||||
a11y: {
|
||||
enableKeyboardNav: finalConfig.enableKeyboardNav,
|
||||
enableScreenReaderAnnouncements: true,
|
||||
keyboardShortcutsEnabled: true
|
||||
},
|
||||
mobileUI: {
|
||||
bottomSheetEnabled: true,
|
||||
adaptiveToolbar: true,
|
||||
swipeIndicators: true,
|
||||
gestureHints: true
|
||||
},
|
||||
performance: {
|
||||
adaptiveQuality: true,
|
||||
batteryOptimization: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced callbacks
|
||||
const enhancedCallbacks: MediaViewerCallbacks = {
|
||||
onOpen: () => {
|
||||
this.onGalleryOpen();
|
||||
callbacks?.onOpen?.();
|
||||
},
|
||||
onClose: () => {
|
||||
this.onGalleryClose();
|
||||
callbacks?.onClose?.();
|
||||
},
|
||||
onChange: (index) => {
|
||||
this.onSlideChange(index);
|
||||
callbacks?.onChange?.(index);
|
||||
},
|
||||
onImageLoad: callbacks?.onImageLoad,
|
||||
onImageError: callbacks?.onImageError
|
||||
};
|
||||
|
||||
// Open with media viewer
|
||||
mediaViewer.open(items, startIndex, photoSwipeConfig, enhancedCallbacks);
|
||||
|
||||
// Setup gallery UI enhancements
|
||||
this.setupGalleryUI();
|
||||
|
||||
// Start slideshow if configured
|
||||
if (finalConfig.autoPlay) {
|
||||
this.startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup gallery UI enhancements
|
||||
*/
|
||||
private setupGalleryUI(): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.setupTimeout) {
|
||||
clearTimeout(this.setupTimeout);
|
||||
}
|
||||
|
||||
// Add gallery-specific UI elements to PhotoSwipe
|
||||
this.setupTimeout = window.setTimeout(() => {
|
||||
// Validate gallery is still open before manipulating DOM
|
||||
if (!this.currentGallery || !this.isGalleryOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// PhotoSwipe needs a moment to initialize
|
||||
const pswpElement = document.querySelector('.pswp');
|
||||
if (!pswpElement) return;
|
||||
|
||||
// Add thumbnail strip if enabled
|
||||
if (this.currentGallery.config.showThumbnails) {
|
||||
this.addThumbnailStrip(pswpElement);
|
||||
}
|
||||
|
||||
// Add slideshow controls
|
||||
this.addSlideshowControls(pswpElement);
|
||||
|
||||
// Add image counter if enabled
|
||||
if (this.currentGallery.config.showCounter) {
|
||||
this.addImageCounter(pswpElement);
|
||||
}
|
||||
|
||||
// Add keyboard hints
|
||||
this.addKeyboardHints(pswpElement);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add thumbnail strip navigation
|
||||
*/
|
||||
private addThumbnailStrip(container: Element): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
// Create thumbnail strip container safely using DOM APIs
|
||||
const stripDiv = document.createElement('div');
|
||||
stripDiv.className = 'gallery-thumbnail-strip';
|
||||
stripDiv.setAttribute('style', `
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 8px;
|
||||
max-width: 90%;
|
||||
overflow-x: auto;
|
||||
z-index: 100;
|
||||
`);
|
||||
|
||||
// Create thumbnails safely
|
||||
this.currentGallery.items.forEach((item, index) => {
|
||||
const thumbDiv = document.createElement('div');
|
||||
thumbDiv.className = 'gallery-thumbnail';
|
||||
thumbDiv.dataset.index = index.toString();
|
||||
thumbDiv.setAttribute('style', `
|
||||
width: ${this.currentGallery!.config.thumbnailHeight}px;
|
||||
height: ${this.currentGallery!.config.thumbnailHeight}px;
|
||||
cursor: pointer;
|
||||
border: 2px solid ${index === this.currentGallery!.currentIndex ? '#fff' : 'transparent'};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
opacity: ${index === this.currentGallery!.currentIndex ? '1' : '0.6'};
|
||||
transition: all 0.2s;
|
||||
`);
|
||||
|
||||
const img = document.createElement('img');
|
||||
// Sanitize src URLs
|
||||
const src = this.sanitizeUrl(item.msrc || item.src);
|
||||
img.src = src;
|
||||
// Use textContent for safe text insertion
|
||||
img.alt = this.sanitizeText(item.alt || '');
|
||||
img.setAttribute('style', `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`);
|
||||
|
||||
thumbDiv.appendChild(img);
|
||||
stripDiv.appendChild(thumbDiv);
|
||||
});
|
||||
|
||||
this.$thumbnailStrip = $(stripDiv);
|
||||
$(container).append(this.$thumbnailStrip);
|
||||
this.createdElements.set('thumbnailStrip', this.$thumbnailStrip);
|
||||
|
||||
// Handle thumbnail clicks
|
||||
this.$thumbnailStrip.on('click', '.gallery-thumbnail', (e) => {
|
||||
const index = parseInt($(e.currentTarget).data('index'));
|
||||
this.goToSlide(index);
|
||||
});
|
||||
|
||||
// Handle hover effect
|
||||
this.$thumbnailStrip.on('mouseenter', '.gallery-thumbnail', (e) => {
|
||||
if (!$(e.currentTarget).hasClass('active')) {
|
||||
$(e.currentTarget).css('opacity', '0.8');
|
||||
}
|
||||
});
|
||||
|
||||
this.$thumbnailStrip.on('mouseleave', '.gallery-thumbnail', (e) => {
|
||||
if (!$(e.currentTarget).hasClass('active')) {
|
||||
$(e.currentTarget).css('opacity', '0.6');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text content to prevent XSS
|
||||
*/
|
||||
private sanitizeText(text: string): string {
|
||||
// Remove any HTML tags and entities
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize URL to prevent XSS
|
||||
*/
|
||||
private sanitizeUrl(url: string): string {
|
||||
// Only allow safe protocols
|
||||
const allowedProtocols = ['http:', 'https:', 'data:'];
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.href);
|
||||
|
||||
// Special validation for data URLs
|
||||
if (urlObj.protocol === 'data:') {
|
||||
// Only allow image MIME types for data URLs
|
||||
const allowedImageTypes = [
|
||||
'data:image/jpeg',
|
||||
'data:image/jpg',
|
||||
'data:image/png',
|
||||
'data:image/gif',
|
||||
'data:image/webp',
|
||||
'data:image/svg+xml',
|
||||
'data:image/bmp'
|
||||
];
|
||||
|
||||
// Check if data URL starts with an allowed image type
|
||||
const isAllowedImage = allowedImageTypes.some(type =>
|
||||
url.toLowerCase().startsWith(type)
|
||||
);
|
||||
|
||||
if (!isAllowedImage) {
|
||||
console.warn('Rejected non-image data URL:', url.substring(0, 50));
|
||||
return '';
|
||||
}
|
||||
|
||||
// Additional check for base64 encoding
|
||||
if (!url.includes(';base64,') && !url.includes(';charset=')) {
|
||||
console.warn('Rejected data URL with invalid encoding');
|
||||
return '';
|
||||
}
|
||||
} else if (!allowedProtocols.includes(urlObj.protocol)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return urlObj.href;
|
||||
} catch {
|
||||
// If URL parsing fails, check if it's a relative path
|
||||
if (url.startsWith('/') || url.startsWith('api/')) {
|
||||
return url;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add slideshow controls
|
||||
*/
|
||||
private addSlideshowControls(container: Element): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
const controlsHtml = `
|
||||
<div class="gallery-slideshow-controls" style="
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 100;
|
||||
">
|
||||
<button class="slideshow-play-pause" style="
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
" aria-label="${this.currentGallery.isPlaying ? 'Pause slideshow' : 'Play slideshow'}">
|
||||
<i class="bx ${this.currentGallery.isPlaying ? 'bx-pause' : 'bx-play'}"></i>
|
||||
</button>
|
||||
|
||||
<button class="slideshow-settings" style="
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
" aria-label="Slideshow settings">
|
||||
<i class="bx bx-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slideshow-interval-selector" style="
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
z-index: 101;
|
||||
">
|
||||
<label style="display: block; margin-bottom: 5px;">Slide interval:</label>
|
||||
<select class="interval-select" style="
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
">
|
||||
<option value="3000">3 seconds</option>
|
||||
<option value="4000" selected>4 seconds</option>
|
||||
<option value="5000">5 seconds</option>
|
||||
<option value="7000">7 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.$slideshowControls = $(controlsHtml);
|
||||
$(container).append(this.$slideshowControls);
|
||||
this.createdElements.set('slideshowControls', this.$slideshowControls);
|
||||
|
||||
// Handle play/pause button
|
||||
this.$slideshowControls.find('.slideshow-play-pause').on('click', () => {
|
||||
this.toggleSlideshow();
|
||||
});
|
||||
|
||||
// Handle settings button
|
||||
this.$slideshowControls.find('.slideshow-settings').on('click', () => {
|
||||
const $selector = this.$slideshowControls?.find('.slideshow-interval-selector');
|
||||
$selector?.toggle();
|
||||
});
|
||||
|
||||
// Handle interval change
|
||||
this.$slideshowControls.find('.interval-select').on('change', (e) => {
|
||||
const interval = parseInt($(e.target).val() as string);
|
||||
this.updateSlideshowInterval(interval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image counter
|
||||
*/
|
||||
private addImageCounter(container: Element): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
// Create counter element safely
|
||||
const counterDiv = document.createElement('div');
|
||||
counterDiv.className = 'gallery-counter';
|
||||
counterDiv.setAttribute('style', `
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
`);
|
||||
|
||||
const currentSpan = document.createElement('span');
|
||||
currentSpan.className = 'current-index';
|
||||
currentSpan.textContent = String(this.currentGallery.currentIndex + 1);
|
||||
|
||||
const separatorSpan = document.createElement('span');
|
||||
separatorSpan.textContent = ' / ';
|
||||
|
||||
const totalSpan = document.createElement('span');
|
||||
totalSpan.className = 'total-count';
|
||||
totalSpan.textContent = String(this.currentGallery.items.length);
|
||||
|
||||
counterDiv.appendChild(currentSpan);
|
||||
counterDiv.appendChild(separatorSpan);
|
||||
counterDiv.appendChild(totalSpan);
|
||||
|
||||
container.appendChild(counterDiv);
|
||||
this.createdElements.set('counter', counterDiv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard hints overlay
|
||||
*/
|
||||
private addKeyboardHints(container: Element): void {
|
||||
// Create hints element safely
|
||||
const hintsDiv = document.createElement('div');
|
||||
hintsDiv.className = 'gallery-keyboard-hints';
|
||||
hintsDiv.setAttribute('style', `
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 100;
|
||||
`);
|
||||
|
||||
// Create hint items
|
||||
const hints = [
|
||||
{ key: '←/→', action: 'Navigate' },
|
||||
{ key: 'Space', action: 'Play/Pause' },
|
||||
{ key: 'ESC', action: 'Close' }
|
||||
];
|
||||
|
||||
hints.forEach(hint => {
|
||||
const hintItem = document.createElement('div');
|
||||
const kbd = document.createElement('kbd');
|
||||
kbd.style.cssText = 'background: rgba(255,255,255,0.2); padding: 2px 4px; border-radius: 2px;';
|
||||
kbd.textContent = hint.key;
|
||||
hintItem.appendChild(kbd);
|
||||
hintItem.appendChild(document.createTextNode(' ' + hint.action));
|
||||
hintsDiv.appendChild(hintItem);
|
||||
});
|
||||
|
||||
container.appendChild(hintsDiv);
|
||||
this.createdElements.set('keyboardHints', hintsDiv);
|
||||
|
||||
const $hints = $(hintsDiv);
|
||||
|
||||
// Show hints on hover with scoped selector
|
||||
const handleMouseEnter = () => {
|
||||
if (this.currentGallery) {
|
||||
$hints.css('opacity', '0.6');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
$hints.css('opacity', '0');
|
||||
};
|
||||
|
||||
$(container).on('mouseenter.galleryHints', handleMouseEnter);
|
||||
$(container).on('mouseleave.galleryHints', handleMouseLeave);
|
||||
|
||||
// Track cleanup callback
|
||||
this.slideshowCallbacks.add(() => {
|
||||
$(container).off('.galleryHints');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gallery open event
|
||||
*/
|
||||
private onGalleryOpen(): void {
|
||||
// Add keyboard listener for slideshow control
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.toggleSlideshow();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
this.slideshowCallbacks.add(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gallery close event
|
||||
*/
|
||||
private onGalleryClose(): void {
|
||||
this.stopSlideshow();
|
||||
|
||||
// Clear setup timeout if exists
|
||||
if (this.setupTimeout) {
|
||||
clearTimeout(this.setupTimeout);
|
||||
this.setupTimeout = undefined;
|
||||
}
|
||||
|
||||
// Cleanup event listeners
|
||||
this.slideshowCallbacks.forEach(callback => callback());
|
||||
this.slideshowCallbacks.clear();
|
||||
|
||||
// Remove all tracked UI elements
|
||||
this.createdElements.forEach((element, key) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.remove();
|
||||
} else if (element instanceof $) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
this.createdElements.clear();
|
||||
|
||||
// Clear jQuery references
|
||||
this.$thumbnailStrip = undefined;
|
||||
this.$slideshowControls = undefined;
|
||||
|
||||
// Clear state
|
||||
this.currentGallery = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle slide change event
|
||||
*/
|
||||
private onSlideChange(index: number): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
this.currentGallery.currentIndex = index;
|
||||
|
||||
// Update thumbnail highlighting
|
||||
if (this.$thumbnailStrip) {
|
||||
this.$thumbnailStrip.find('.gallery-thumbnail').each((i, el) => {
|
||||
const $thumb = $(el);
|
||||
if (i === index) {
|
||||
$thumb.css({
|
||||
'border-color': '#fff',
|
||||
'opacity': '1'
|
||||
});
|
||||
|
||||
// Scroll thumbnail into view
|
||||
const thumbLeft = $thumb.position().left;
|
||||
const thumbWidth = $thumb.outerWidth() || 0;
|
||||
const stripWidth = this.$thumbnailStrip!.width() || 0;
|
||||
const scrollLeft = this.$thumbnailStrip!.scrollLeft() || 0;
|
||||
|
||||
if (thumbLeft < 0) {
|
||||
this.$thumbnailStrip!.scrollLeft(scrollLeft + thumbLeft - 10);
|
||||
} else if (thumbLeft + thumbWidth > stripWidth) {
|
||||
this.$thumbnailStrip!.scrollLeft(scrollLeft + (thumbLeft + thumbWidth - stripWidth) + 10);
|
||||
}
|
||||
} else {
|
||||
$thumb.css({
|
||||
'border-color': 'transparent',
|
||||
'opacity': '0.6'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update counter using tracked element
|
||||
const counterElement = this.createdElements.get('counter');
|
||||
if (counterElement instanceof HTMLElement) {
|
||||
const currentIndexElement = counterElement.querySelector('.current-index');
|
||||
if (currentIndexElement) {
|
||||
currentIndexElement.textContent = String(index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start slideshow
|
||||
*/
|
||||
startSlideshow(): void {
|
||||
if (!this.currentGallery || this.currentGallery.isPlaying) return;
|
||||
|
||||
// Validate gallery state before starting slideshow
|
||||
if (!this.isGalleryOpen() || this.currentGallery.items.length === 0) {
|
||||
console.warn('Cannot start slideshow: gallery not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure PhotoSwipe is ready
|
||||
if (!mediaViewer.isOpen()) {
|
||||
console.warn('Cannot start slideshow: PhotoSwipe not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentGallery.isPlaying = true;
|
||||
|
||||
// Update button icon
|
||||
this.$slideshowControls?.find('.slideshow-play-pause i')
|
||||
.removeClass('bx-play')
|
||||
.addClass('bx-pause');
|
||||
|
||||
// Start timer
|
||||
this.scheduleNextSlide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop slideshow
|
||||
*/
|
||||
stopSlideshow(): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
this.currentGallery.isPlaying = false;
|
||||
|
||||
// Clear timer
|
||||
if (this.currentGallery.slideshowTimer) {
|
||||
clearTimeout(this.currentGallery.slideshowTimer);
|
||||
this.currentGallery.slideshowTimer = undefined;
|
||||
}
|
||||
|
||||
// Update button icon
|
||||
this.$slideshowControls?.find('.slideshow-play-pause i')
|
||||
.removeClass('bx-pause')
|
||||
.addClass('bx-play');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle slideshow play/pause
|
||||
*/
|
||||
toggleSlideshow(): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
if (this.currentGallery.isPlaying) {
|
||||
this.stopSlideshow();
|
||||
} else {
|
||||
this.startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next slide in slideshow
|
||||
*/
|
||||
private scheduleNextSlide(): void {
|
||||
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
|
||||
|
||||
// Clear any existing timer
|
||||
if (this.currentGallery.slideshowTimer) {
|
||||
clearTimeout(this.currentGallery.slideshowTimer);
|
||||
}
|
||||
|
||||
this.currentGallery.slideshowTimer = window.setTimeout(() => {
|
||||
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
|
||||
|
||||
// Go to next slide
|
||||
const nextIndex = (this.currentGallery.currentIndex + 1) % this.currentGallery.items.length;
|
||||
this.goToSlide(nextIndex);
|
||||
|
||||
// Schedule next transition
|
||||
this.scheduleNextSlide();
|
||||
}, this.currentGallery.config.slideInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update slideshow interval
|
||||
*/
|
||||
updateSlideshowInterval(interval: number): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
this.currentGallery.config.slideInterval = interval;
|
||||
|
||||
// Restart slideshow with new interval if playing
|
||||
if (this.currentGallery.isPlaying) {
|
||||
this.stopSlideshow();
|
||||
this.startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific slide
|
||||
*/
|
||||
goToSlide(index: number): void {
|
||||
if (!this.currentGallery) return;
|
||||
|
||||
if (index >= 0 && index < this.currentGallery.items.length) {
|
||||
mediaViewer.goTo(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next slide
|
||||
*/
|
||||
nextSlide(): void {
|
||||
mediaViewer.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous slide
|
||||
*/
|
||||
previousSlide(): void {
|
||||
mediaViewer.prev();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close gallery
|
||||
*/
|
||||
closeGallery(): void {
|
||||
mediaViewer.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gallery is open
|
||||
*/
|
||||
isGalleryOpen(): boolean {
|
||||
return this.currentGallery !== null && mediaViewer.isOpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current gallery state
|
||||
*/
|
||||
getGalleryState(): GalleryState | null {
|
||||
return this.currentGallery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve image source URL
|
||||
*/
|
||||
private resolveImageSrc(src: string, noteId: string): string {
|
||||
// Handle different image source formats
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
if (src.startsWith('api/images/')) {
|
||||
return `/${src}`;
|
||||
}
|
||||
|
||||
if (src.startsWith('/')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// Assume it's a note ID or attachment reference
|
||||
return `/api/images/${noteId}/${src}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Clear any pending timeouts
|
||||
if (this.setupTimeout) {
|
||||
clearTimeout(this.setupTimeout);
|
||||
this.setupTimeout = undefined;
|
||||
}
|
||||
|
||||
this.closeGallery();
|
||||
|
||||
// Ensure all elements are removed
|
||||
this.createdElements.forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.remove();
|
||||
} else if (element instanceof $) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
this.createdElements.clear();
|
||||
|
||||
this.slideshowCallbacks.clear();
|
||||
this.currentGallery = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default GalleryManager.getInstance();
|
||||
@@ -3,6 +3,7 @@ import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
@@ -16,6 +17,7 @@ export async function initLocale() {
|
||||
|
||||
locales = await server.get<Locale[]>("options/locales");
|
||||
|
||||
i18next.use(initReactI18next);
|
||||
await i18next.use(i18nextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "./i18n.js";
|
||||
import toastService, { showError } from "./toast.js";
|
||||
|
||||
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
|
||||
export function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
|
||||
try {
|
||||
$imageWrapper.attr("contenteditable", "true");
|
||||
selectImage($imageWrapper.get(0));
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
/**
|
||||
* Image Annotations Module for PhotoSwipe
|
||||
* Provides ability to add, display, and manage annotations on images
|
||||
*/
|
||||
|
||||
import froca from './froca.js';
|
||||
import server from './server.js';
|
||||
import type FNote from '../entities/fnote.js';
|
||||
import type FAttribute from '../entities/fattribute.js';
|
||||
import { ImageValidator, withErrorBoundary, ImageError, ImageErrorType } from './image_error_handler.js';
|
||||
|
||||
/**
|
||||
* Annotation position and data
|
||||
*/
|
||||
export interface ImageAnnotation {
|
||||
id: string;
|
||||
noteId: string;
|
||||
x: number; // Percentage from left (0-100)
|
||||
y: number; // Percentage from top (0-100)
|
||||
text: string;
|
||||
author?: string;
|
||||
created: Date;
|
||||
modified?: Date;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
type?: 'comment' | 'marker' | 'region';
|
||||
width?: number; // For region type
|
||||
height?: number; // For region type
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation configuration
|
||||
*/
|
||||
export interface AnnotationConfig {
|
||||
enableAnnotations: boolean;
|
||||
showByDefault: boolean;
|
||||
allowEditing: boolean;
|
||||
defaultColor: string;
|
||||
defaultIcon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageAnnotationsService manages image annotations using Trilium's attribute system
|
||||
*/
|
||||
class ImageAnnotationsService {
|
||||
private static instance: ImageAnnotationsService;
|
||||
private activeAnnotations: Map<string, ImageAnnotation[]> = new Map();
|
||||
private annotationElements: Map<string, HTMLElement> = new Map();
|
||||
private isEditMode: boolean = false;
|
||||
private selectedAnnotation: ImageAnnotation | null = null;
|
||||
|
||||
private config: AnnotationConfig = {
|
||||
enableAnnotations: true,
|
||||
showByDefault: true,
|
||||
allowEditing: true,
|
||||
defaultColor: '#ffeb3b',
|
||||
defaultIcon: 'bx-comment'
|
||||
};
|
||||
|
||||
// Annotation attribute prefix in Trilium
|
||||
private readonly ANNOTATION_PREFIX = 'imageAnnotation';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ImageAnnotationsService {
|
||||
if (!ImageAnnotationsService.instance) {
|
||||
ImageAnnotationsService.instance = new ImageAnnotationsService();
|
||||
}
|
||||
return ImageAnnotationsService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load annotations for an image note
|
||||
*/
|
||||
async loadAnnotations(noteId: string): Promise<ImageAnnotation[]> {
|
||||
return await withErrorBoundary(async () => {
|
||||
// Validate note ID
|
||||
if (!noteId || typeof noteId !== 'string') {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'Invalid note ID provided'
|
||||
);
|
||||
}
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) return [];
|
||||
|
||||
const attributes = note.getAttributes();
|
||||
const annotations: ImageAnnotation[] = [];
|
||||
|
||||
// Parse annotation attributes
|
||||
for (const attr of attributes) {
|
||||
if (attr.name.startsWith(this.ANNOTATION_PREFIX)) {
|
||||
try {
|
||||
const annotationData = JSON.parse(attr.value);
|
||||
annotations.push({
|
||||
...annotationData,
|
||||
id: attr.attributeId,
|
||||
noteId: noteId,
|
||||
created: new Date(annotationData.created),
|
||||
modified: annotationData.modified ? new Date(annotationData.modified) : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse annotation:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date
|
||||
annotations.sort((a, b) => a.created.getTime() - b.created.getTime());
|
||||
|
||||
this.activeAnnotations.set(noteId, annotations);
|
||||
return annotations;
|
||||
}) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new annotation
|
||||
*/
|
||||
async saveAnnotation(annotation: Omit<ImageAnnotation, 'id' | 'created'>): Promise<ImageAnnotation> {
|
||||
return await withErrorBoundary(async () => {
|
||||
// Validate annotation data
|
||||
if (!annotation.text || !annotation.noteId) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'Invalid annotation data'
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize text
|
||||
annotation.text = this.sanitizeText(annotation.text);
|
||||
const note = await froca.getNote(annotation.noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const newAnnotation: ImageAnnotation = {
|
||||
...annotation,
|
||||
id: this.generateId(),
|
||||
created: new Date()
|
||||
};
|
||||
|
||||
// Save as note attribute
|
||||
const attributeName = `${this.ANNOTATION_PREFIX}_${newAnnotation.id}`;
|
||||
const attributeValue = JSON.stringify({
|
||||
x: newAnnotation.x,
|
||||
y: newAnnotation.y,
|
||||
text: newAnnotation.text,
|
||||
author: newAnnotation.author,
|
||||
created: newAnnotation.created.toISOString(),
|
||||
color: newAnnotation.color,
|
||||
icon: newAnnotation.icon,
|
||||
type: newAnnotation.type,
|
||||
width: newAnnotation.width,
|
||||
height: newAnnotation.height
|
||||
});
|
||||
|
||||
await server.put(`notes/${annotation.noteId}/attributes`, {
|
||||
attributes: [{
|
||||
type: 'label',
|
||||
name: attributeName,
|
||||
value: attributeValue
|
||||
}]
|
||||
});
|
||||
|
||||
// Update cache
|
||||
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
|
||||
annotations.push(newAnnotation);
|
||||
this.activeAnnotations.set(annotation.noteId, annotations);
|
||||
|
||||
return newAnnotation;
|
||||
}) as Promise<ImageAnnotation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing annotation
|
||||
*/
|
||||
async updateAnnotation(annotation: ImageAnnotation): Promise<void> {
|
||||
try {
|
||||
const note = await froca.getNote(annotation.noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
annotation.modified = new Date();
|
||||
|
||||
// Update attribute
|
||||
const attributeName = `${this.ANNOTATION_PREFIX}_${annotation.id}`;
|
||||
const attributeValue = JSON.stringify({
|
||||
x: annotation.x,
|
||||
y: annotation.y,
|
||||
text: annotation.text,
|
||||
author: annotation.author,
|
||||
created: annotation.created.toISOString(),
|
||||
modified: annotation.modified.toISOString(),
|
||||
color: annotation.color,
|
||||
icon: annotation.icon,
|
||||
type: annotation.type,
|
||||
width: annotation.width,
|
||||
height: annotation.height
|
||||
});
|
||||
|
||||
// Find and update the attribute
|
||||
const attributes = note.getAttributes();
|
||||
const attr = attributes.find(a => a.name === attributeName);
|
||||
|
||||
if (attr) {
|
||||
await server.put(`notes/${annotation.noteId}/attributes/${attr.attributeId}`, {
|
||||
value: attributeValue
|
||||
});
|
||||
}
|
||||
|
||||
// Update cache
|
||||
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
|
||||
const index = annotations.findIndex(a => a.id === annotation.id);
|
||||
if (index !== -1) {
|
||||
annotations[index] = annotation;
|
||||
this.activeAnnotations.set(annotation.noteId, annotations);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update annotation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an annotation
|
||||
*/
|
||||
async deleteAnnotation(noteId: string, annotationId: string): Promise<void> {
|
||||
try {
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) return;
|
||||
|
||||
const attributeName = `${this.ANNOTATION_PREFIX}_${annotationId}`;
|
||||
const attributes = note.getAttributes();
|
||||
const attr = attributes.find(a => a.name === attributeName);
|
||||
|
||||
if (attr) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attr.attributeId}`);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
const annotations = this.activeAnnotations.get(noteId) || [];
|
||||
const filtered = annotations.filter(a => a.id !== annotationId);
|
||||
this.activeAnnotations.set(noteId, filtered);
|
||||
|
||||
// Remove element if exists
|
||||
const element = this.annotationElements.get(annotationId);
|
||||
if (element) {
|
||||
element.remove();
|
||||
this.annotationElements.delete(annotationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete annotation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render annotations on an image container
|
||||
*/
|
||||
renderAnnotations(container: HTMLElement, noteId: string, imageElement: HTMLImageElement): void {
|
||||
const annotations = this.activeAnnotations.get(noteId) || [];
|
||||
|
||||
// Clear existing annotation elements
|
||||
this.clearAnnotationElements();
|
||||
|
||||
// Create annotation overlay container
|
||||
const overlay = this.createOverlayContainer(container, imageElement);
|
||||
|
||||
// Render each annotation
|
||||
annotations.forEach(annotation => {
|
||||
const element = this.createAnnotationElement(annotation, overlay);
|
||||
this.annotationElements.set(annotation.id, element);
|
||||
});
|
||||
|
||||
// Add click handler for creating new annotations
|
||||
if (this.config.allowEditing && this.isEditMode) {
|
||||
this.setupAnnotationCreation(overlay, noteId);
|
||||
}
|
||||
|
||||
// Add ARIA attributes for accessibility
|
||||
overlay.setAttribute('role', 'img');
|
||||
overlay.setAttribute('aria-label', 'Image with annotations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create overlay container for annotations
|
||||
*/
|
||||
private createOverlayContainer(container: HTMLElement, imageElement: HTMLImageElement): HTMLElement {
|
||||
let overlay = container.querySelector('.annotation-overlay') as HTMLElement;
|
||||
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'annotation-overlay';
|
||||
overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: ${this.isEditMode ? 'auto' : 'none'};
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
// Position overlay over the image
|
||||
const rect = imageElement.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
overlay.style.top = `${rect.top - containerRect.top}px`;
|
||||
overlay.style.left = `${rect.left - containerRect.left}px`;
|
||||
overlay.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${rect.height}px`;
|
||||
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create annotation element
|
||||
*/
|
||||
private createAnnotationElement(annotation: ImageAnnotation, container: HTMLElement): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = `annotation-marker annotation-${annotation.type || 'comment'}`;
|
||||
element.dataset.annotationId = annotation.id;
|
||||
|
||||
// Position based on percentage
|
||||
element.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${annotation.x}%;
|
||||
top: ${annotation.y}%;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
`;
|
||||
|
||||
// Create marker based on type
|
||||
if (annotation.type === 'region') {
|
||||
// Region annotation
|
||||
element.style.cssText += `
|
||||
width: ${annotation.width || 20}%;
|
||||
height: ${annotation.height || 20}%;
|
||||
border: 2px solid ${annotation.color || this.config.defaultColor};
|
||||
background: ${annotation.color || this.config.defaultColor}33;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
} else {
|
||||
// Point annotation
|
||||
const marker = document.createElement('div');
|
||||
marker.style.cssText = `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: ${annotation.color || this.config.defaultColor};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = `bx ${annotation.icon || this.config.defaultIcon}`;
|
||||
icon.style.cssText = `
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
marker.appendChild(icon);
|
||||
element.appendChild(marker);
|
||||
|
||||
// Add ARIA attributes for accessibility
|
||||
element.setAttribute('role', 'button');
|
||||
element.setAttribute('aria-label', `Annotation: ${this.sanitizeText(annotation.text)}`);
|
||||
element.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
// Add tooltip
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'annotation-tooltip';
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
// Use textContent to prevent XSS
|
||||
tooltip.textContent = this.sanitizeText(annotation.text);
|
||||
element.appendChild(tooltip);
|
||||
|
||||
// Show tooltip on hover
|
||||
element.addEventListener('mouseenter', () => {
|
||||
tooltip.style.opacity = '1';
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
tooltip.style.opacity = '0';
|
||||
});
|
||||
|
||||
// Handle click for editing
|
||||
element.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectAnnotation(annotation);
|
||||
});
|
||||
|
||||
container.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup annotation creation on click
|
||||
*/
|
||||
private setupAnnotationCreation(overlay: HTMLElement, noteId: string): void {
|
||||
overlay.addEventListener('click', async (e) => {
|
||||
if (!this.isEditMode) return;
|
||||
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
// Show annotation creation dialog
|
||||
const text = prompt('Enter annotation text:');
|
||||
if (text) {
|
||||
await this.saveAnnotation({
|
||||
noteId,
|
||||
x,
|
||||
y,
|
||||
text,
|
||||
author: 'current_user', // TODO: Get from session
|
||||
type: 'comment'
|
||||
});
|
||||
|
||||
// Reload annotations
|
||||
await this.loadAnnotations(noteId);
|
||||
|
||||
// Re-render
|
||||
const imageElement = overlay.parentElement?.querySelector('img') as HTMLImageElement;
|
||||
if (imageElement && overlay.parentElement) {
|
||||
this.renderAnnotations(overlay.parentElement, noteId, imageElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an annotation for editing
|
||||
*/
|
||||
private selectAnnotation(annotation: ImageAnnotation): void {
|
||||
this.selectedAnnotation = annotation;
|
||||
|
||||
// Highlight selected annotation
|
||||
this.annotationElements.forEach((element, id) => {
|
||||
if (id === annotation.id) {
|
||||
element.classList.add('selected');
|
||||
element.style.outline = '2px solid #2196F3';
|
||||
} else {
|
||||
element.classList.remove('selected');
|
||||
element.style.outline = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Show edit options
|
||||
if (this.isEditMode) {
|
||||
this.showEditDialog(annotation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit dialog for annotation
|
||||
*/
|
||||
private showEditDialog(annotation: ImageAnnotation): void {
|
||||
// Simple implementation - could be replaced with a proper modal
|
||||
const newText = prompt('Edit annotation:', annotation.text);
|
||||
if (newText !== null) {
|
||||
annotation.text = newText;
|
||||
this.updateAnnotation(annotation);
|
||||
|
||||
// Update tooltip with sanitized text
|
||||
const element = this.annotationElements.get(annotation.id);
|
||||
if (element) {
|
||||
const tooltip = element.querySelector('.annotation-tooltip');
|
||||
if (tooltip) {
|
||||
// Use textContent to prevent XSS
|
||||
tooltip.textContent = this.sanitizeText(newText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle edit mode
|
||||
*/
|
||||
toggleEditMode(): void {
|
||||
this.isEditMode = !this.isEditMode;
|
||||
|
||||
// Update overlay pointer events
|
||||
document.querySelectorAll('.annotation-overlay').forEach(overlay => {
|
||||
(overlay as HTMLElement).style.pointerEvents = this.isEditMode ? 'auto' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all annotation elements
|
||||
*/
|
||||
private clearAnnotationElements(): void {
|
||||
this.annotationElements.forEach(element => element.remove());
|
||||
this.annotationElements.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export annotations as JSON
|
||||
*/
|
||||
exportAnnotations(noteId: string): string {
|
||||
const annotations = this.activeAnnotations.get(noteId) || [];
|
||||
return JSON.stringify(annotations, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import annotations from JSON
|
||||
*/
|
||||
async importAnnotations(noteId: string, json: string): Promise<void> {
|
||||
try {
|
||||
const annotations = JSON.parse(json) as ImageAnnotation[];
|
||||
|
||||
for (const annotation of annotations) {
|
||||
await this.saveAnnotation({
|
||||
noteId,
|
||||
x: annotation.x,
|
||||
y: annotation.y,
|
||||
text: annotation.text,
|
||||
author: annotation.author,
|
||||
color: annotation.color,
|
||||
icon: annotation.icon,
|
||||
type: annotation.type,
|
||||
width: annotation.width,
|
||||
height: annotation.height
|
||||
});
|
||||
}
|
||||
|
||||
await this.loadAnnotations(noteId);
|
||||
} catch (error) {
|
||||
console.error('Failed to import annotations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text to prevent XSS
|
||||
*/
|
||||
private sanitizeText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove any HTML tags and dangerous characters
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
|
||||
// Additional validation
|
||||
const sanitized = div.textContent || '';
|
||||
|
||||
// Remove any remaining special characters that could be dangerous
|
||||
return sanitized
|
||||
.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
||||
.replace(/<iframe[^>]*>.*?<\/iframe>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.clearAnnotationElements();
|
||||
this.activeAnnotations.clear();
|
||||
this.selectedAnnotation = null;
|
||||
this.isEditMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageAnnotationsService.getInstance();
|
||||
@@ -1,877 +0,0 @@
|
||||
/**
|
||||
* Image Comparison Module for Trilium Notes
|
||||
* Provides side-by-side and overlay comparison modes for images
|
||||
*/
|
||||
|
||||
import mediaViewer from './media_viewer.js';
|
||||
import utils from './utils.js';
|
||||
|
||||
/**
|
||||
* Comparison mode types
|
||||
*/
|
||||
export type ComparisonMode = 'side-by-side' | 'overlay' | 'swipe' | 'difference';
|
||||
|
||||
/**
|
||||
* Image comparison configuration
|
||||
*/
|
||||
export interface ComparisonConfig {
|
||||
mode: ComparisonMode;
|
||||
syncZoom: boolean;
|
||||
syncPan: boolean;
|
||||
showLabels: boolean;
|
||||
swipePosition?: number; // For swipe mode (0-100)
|
||||
opacity?: number; // For overlay mode (0-1)
|
||||
highlightDifferences?: boolean; // For difference mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison state
|
||||
*/
|
||||
interface ComparisonState {
|
||||
leftImage: ComparisonImage;
|
||||
rightImage: ComparisonImage;
|
||||
config: ComparisonConfig;
|
||||
container?: HTMLElement;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image data for comparison
|
||||
*/
|
||||
export interface ComparisonImage {
|
||||
src: string;
|
||||
title?: string;
|
||||
noteId?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageComparisonService provides various comparison modes for images
|
||||
*/
|
||||
class ImageComparisonService {
|
||||
private static instance: ImageComparisonService;
|
||||
private currentComparison: ComparisonState | null = null;
|
||||
private comparisonContainer?: HTMLElement;
|
||||
private leftCanvas?: HTMLCanvasElement;
|
||||
private rightCanvas?: HTMLCanvasElement;
|
||||
private leftContext?: CanvasRenderingContext2D;
|
||||
private rightContext?: CanvasRenderingContext2D;
|
||||
private swipeHandle?: HTMLElement;
|
||||
private isDraggingSwipe: boolean = false;
|
||||
private currentZoom: number = 1;
|
||||
private panX: number = 0;
|
||||
private panY: number = 0;
|
||||
|
||||
private defaultConfig: ComparisonConfig = {
|
||||
mode: 'side-by-side',
|
||||
syncZoom: true,
|
||||
syncPan: true,
|
||||
showLabels: true,
|
||||
swipePosition: 50,
|
||||
opacity: 0.5,
|
||||
highlightDifferences: false
|
||||
};
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ImageComparisonService {
|
||||
if (!ImageComparisonService.instance) {
|
||||
ImageComparisonService.instance = new ImageComparisonService();
|
||||
}
|
||||
return ImageComparisonService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start image comparison
|
||||
*/
|
||||
async startComparison(
|
||||
leftImage: ComparisonImage,
|
||||
rightImage: ComparisonImage,
|
||||
container: HTMLElement,
|
||||
config?: Partial<ComparisonConfig>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Close any existing comparison
|
||||
this.closeComparison();
|
||||
|
||||
// Merge configuration
|
||||
const finalConfig = { ...this.defaultConfig, ...config };
|
||||
|
||||
// Initialize state
|
||||
this.currentComparison = {
|
||||
leftImage,
|
||||
rightImage,
|
||||
config: finalConfig,
|
||||
container,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
// Load images
|
||||
await this.loadImages(leftImage, rightImage);
|
||||
|
||||
// Create comparison UI based on mode
|
||||
switch (finalConfig.mode) {
|
||||
case 'side-by-side':
|
||||
await this.createSideBySideComparison(container);
|
||||
break;
|
||||
case 'overlay':
|
||||
await this.createOverlayComparison(container);
|
||||
break;
|
||||
case 'swipe':
|
||||
await this.createSwipeComparison(container);
|
||||
break;
|
||||
case 'difference':
|
||||
await this.createDifferenceComparison(container);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add controls
|
||||
this.addComparisonControls(container);
|
||||
} catch (error) {
|
||||
console.error('Failed to start image comparison:', error);
|
||||
this.closeComparison();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load images and get dimensions
|
||||
*/
|
||||
private async loadImages(leftImage: ComparisonImage, rightImage: ComparisonImage): Promise<void> {
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
const [leftImg, rightImg] = await Promise.all([
|
||||
loadImage(leftImage.src),
|
||||
loadImage(rightImage.src)
|
||||
]);
|
||||
|
||||
// Update dimensions
|
||||
leftImage.width = leftImg.naturalWidth;
|
||||
leftImage.height = leftImg.naturalHeight;
|
||||
rightImage.width = rightImg.naturalWidth;
|
||||
rightImage.height = rightImg.naturalHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create side-by-side comparison
|
||||
*/
|
||||
private async createSideBySideComparison(container: HTMLElement): Promise<void> {
|
||||
if (!this.currentComparison) return;
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
container.style.cssText = `
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
`;
|
||||
|
||||
// Create left panel
|
||||
const leftPanel = document.createElement('div');
|
||||
leftPanel.className = 'comparison-panel comparison-left';
|
||||
leftPanel.style.cssText = `
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-right: 2px solid #333;
|
||||
`;
|
||||
|
||||
// Create right panel
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'comparison-panel comparison-right';
|
||||
rightPanel.style.cssText = `
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
// Add images
|
||||
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
|
||||
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
|
||||
|
||||
leftPanel.appendChild(leftImg);
|
||||
rightPanel.appendChild(rightImg);
|
||||
|
||||
// Add labels if enabled
|
||||
if (this.currentComparison.config.showLabels) {
|
||||
this.addImageLabel(leftPanel, this.currentComparison.leftImage.title || 'Image 1');
|
||||
this.addImageLabel(rightPanel, this.currentComparison.rightImage.title || 'Image 2');
|
||||
}
|
||||
|
||||
container.appendChild(leftPanel);
|
||||
container.appendChild(rightPanel);
|
||||
|
||||
// Setup synchronized zoom and pan if enabled
|
||||
if (this.currentComparison.config.syncZoom || this.currentComparison.config.syncPan) {
|
||||
this.setupSynchronizedControls(leftPanel, rightPanel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create overlay comparison
|
||||
*/
|
||||
private async createOverlayComparison(container: HTMLElement): Promise<void> {
|
||||
if (!this.currentComparison) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
// Create base image
|
||||
const baseImg = await this.createImageElement(this.currentComparison.leftImage);
|
||||
baseImg.style.position = 'absolute';
|
||||
baseImg.style.zIndex = '1';
|
||||
|
||||
// Create overlay image
|
||||
const overlayImg = await this.createImageElement(this.currentComparison.rightImage);
|
||||
overlayImg.style.position = 'absolute';
|
||||
overlayImg.style.zIndex = '2';
|
||||
overlayImg.style.opacity = String(this.currentComparison.config.opacity || 0.5);
|
||||
|
||||
container.appendChild(baseImg);
|
||||
container.appendChild(overlayImg);
|
||||
|
||||
// Add opacity slider
|
||||
this.addOpacityControl(container, overlayImg);
|
||||
|
||||
// Add labels
|
||||
if (this.currentComparison.config.showLabels) {
|
||||
const labelContainer = document.createElement('div');
|
||||
labelContainer.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const baseLabel = this.createLabel(this.currentComparison.leftImage.title || 'Base', '#4CAF50');
|
||||
const overlayLabel = this.createLabel(this.currentComparison.rightImage.title || 'Overlay', '#2196F3');
|
||||
|
||||
labelContainer.appendChild(baseLabel);
|
||||
labelContainer.appendChild(overlayLabel);
|
||||
container.appendChild(labelContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create swipe comparison
|
||||
*/
|
||||
private async createSwipeComparison(container: HTMLElement): Promise<void> {
|
||||
if (!this.currentComparison) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
cursor: ew-resize;
|
||||
`;
|
||||
|
||||
// Create images
|
||||
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
|
||||
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
|
||||
|
||||
leftImg.style.position = 'absolute';
|
||||
leftImg.style.zIndex = '1';
|
||||
|
||||
// Create clipping container for right image
|
||||
const clipContainer = document.createElement('div');
|
||||
clipContainer.className = 'swipe-clip-container';
|
||||
clipContainer.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: ${this.currentComparison.config.swipePosition}%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
rightImg.style.position = 'absolute';
|
||||
clipContainer.appendChild(rightImg);
|
||||
|
||||
// Create swipe handle
|
||||
this.swipeHandle = document.createElement('div');
|
||||
this.swipeHandle.className = 'swipe-handle';
|
||||
this.swipeHandle.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: ${this.currentComparison.config.swipePosition}%;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: white;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
// Add handle icon
|
||||
const handleIcon = document.createElement('div');
|
||||
handleIcon.style.cssText = `
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
`;
|
||||
handleIcon.innerHTML = '<i class="bx bx-move-horizontal" style="font-size: 24px; color: #333;"></i>';
|
||||
this.swipeHandle.appendChild(handleIcon);
|
||||
|
||||
container.appendChild(leftImg);
|
||||
container.appendChild(clipContainer);
|
||||
container.appendChild(this.swipeHandle);
|
||||
|
||||
// Setup swipe interaction
|
||||
this.setupSwipeInteraction(container, clipContainer);
|
||||
|
||||
// Add labels
|
||||
if (this.currentComparison.config.showLabels) {
|
||||
this.addSwipeLabels(container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create difference comparison using canvas
|
||||
*/
|
||||
private async createDifferenceComparison(container: HTMLElement): Promise<void> {
|
||||
if (!this.currentComparison) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
// Create canvas for difference visualization
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'difference-canvas';
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
// Load images
|
||||
const leftImg = new Image();
|
||||
const rightImg = new Image();
|
||||
|
||||
await Promise.all([
|
||||
new Promise((resolve) => {
|
||||
leftImg.onload = resolve;
|
||||
leftImg.src = this.currentComparison!.leftImage.src;
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
rightImg.onload = resolve;
|
||||
rightImg.src = this.currentComparison!.rightImage.src;
|
||||
})
|
||||
]);
|
||||
|
||||
// Set canvas size
|
||||
const maxWidth = Math.max(leftImg.width, rightImg.width);
|
||||
const maxHeight = Math.max(leftImg.height, rightImg.height);
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = maxHeight;
|
||||
|
||||
// Calculate difference
|
||||
this.calculateImageDifference(ctx, leftImg, rightImg, maxWidth, maxHeight);
|
||||
|
||||
// Style canvas
|
||||
canvas.style.cssText = `
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
container.appendChild(canvas);
|
||||
|
||||
// Add difference statistics
|
||||
this.addDifferenceStatistics(container, ctx, maxWidth, maxHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and visualize image difference
|
||||
*/
|
||||
private calculateImageDifference(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
leftImg: HTMLImageElement,
|
||||
rightImg: HTMLImageElement,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
// Draw left image
|
||||
ctx.drawImage(leftImg, 0, 0, width, height);
|
||||
const leftData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// Draw right image
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(rightImg, 0, 0, width, height);
|
||||
const rightData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// Calculate difference
|
||||
const diffData = ctx.createImageData(width, height);
|
||||
let totalDiff = 0;
|
||||
|
||||
for (let i = 0; i < leftData.data.length; i += 4) {
|
||||
const rDiff = Math.abs(leftData.data[i] - rightData.data[i]);
|
||||
const gDiff = Math.abs(leftData.data[i + 1] - rightData.data[i + 1]);
|
||||
const bDiff = Math.abs(leftData.data[i + 2] - rightData.data[i + 2]);
|
||||
|
||||
const avgDiff = (rDiff + gDiff + bDiff) / 3;
|
||||
totalDiff += avgDiff;
|
||||
|
||||
if (this.currentComparison?.config.highlightDifferences && avgDiff > 30) {
|
||||
// Highlight differences in red
|
||||
diffData.data[i] = 255; // Red
|
||||
diffData.data[i + 1] = 0; // Green
|
||||
diffData.data[i + 2] = 0; // Blue
|
||||
diffData.data[i + 3] = Math.min(255, avgDiff * 2); // Alpha based on difference
|
||||
} else {
|
||||
// Show original image with reduced opacity for non-different areas
|
||||
diffData.data[i] = leftData.data[i];
|
||||
diffData.data[i + 1] = leftData.data[i + 1];
|
||||
diffData.data[i + 2] = leftData.data[i + 2];
|
||||
diffData.data[i + 3] = avgDiff > 10 ? 255 : 128;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(diffData, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add difference statistics overlay
|
||||
*/
|
||||
private addDifferenceStatistics(
|
||||
container: HTMLElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
let changedPixels = 0;
|
||||
const threshold = 30;
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const r = imageData.data[i];
|
||||
const g = imageData.data[i + 1];
|
||||
const b = imageData.data[i + 2];
|
||||
|
||||
if (r > threshold || g > threshold || b > threshold) {
|
||||
changedPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPixels = width * height;
|
||||
const changePercentage = ((changedPixels / totalPixels) * 100).toFixed(2);
|
||||
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'difference-stats';
|
||||
statsDiv.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
statsDiv.innerHTML = `
|
||||
<div><strong>Difference Analysis</strong></div>
|
||||
<div>Changed pixels: ${changedPixels.toLocaleString()}</div>
|
||||
<div>Total pixels: ${totalPixels.toLocaleString()}</div>
|
||||
<div>Difference: ${changePercentage}%</div>
|
||||
`;
|
||||
|
||||
container.appendChild(statsDiv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image element
|
||||
*/
|
||||
private async createImageElement(image: ComparisonImage): Promise<HTMLImageElement> {
|
||||
const img = document.createElement('img');
|
||||
img.src = image.src;
|
||||
img.alt = image.title || 'Comparison image';
|
||||
img.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image label
|
||||
*/
|
||||
private addImageLabel(container: HTMLElement, text: string): void {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'image-label';
|
||||
label.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
`;
|
||||
label.textContent = text;
|
||||
container.appendChild(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create label element
|
||||
*/
|
||||
private createLabel(text: string, color: string): HTMLElement {
|
||||
const label = document.createElement('div');
|
||||
label.style.cssText = `
|
||||
background: ${color};
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
label.textContent = text;
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add swipe labels
|
||||
*/
|
||||
private addSwipeLabels(container: HTMLElement): void {
|
||||
if (!this.currentComparison) return;
|
||||
|
||||
const leftLabel = document.createElement('div');
|
||||
leftLabel.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(76, 175, 80, 0.9);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
`;
|
||||
leftLabel.textContent = this.currentComparison.leftImage.title || 'Left';
|
||||
|
||||
const rightLabel = document.createElement('div');
|
||||
rightLabel.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(33, 150, 243, 0.9);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
`;
|
||||
rightLabel.textContent = this.currentComparison.rightImage.title || 'Right';
|
||||
|
||||
container.appendChild(leftLabel);
|
||||
container.appendChild(rightLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup swipe interaction
|
||||
*/
|
||||
private setupSwipeInteraction(container: HTMLElement, clipContainer: HTMLElement): void {
|
||||
if (!this.swipeHandle) return;
|
||||
|
||||
let startX = 0;
|
||||
let startPosition = this.currentComparison?.config.swipePosition || 50;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDraggingSwipe) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
|
||||
clipContainer.style.width = `${percentage}%`;
|
||||
if (this.swipeHandle) {
|
||||
this.swipeHandle.style.left = `${percentage}%`;
|
||||
}
|
||||
|
||||
if (this.currentComparison) {
|
||||
this.currentComparison.config.swipePosition = percentage;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
this.isDraggingSwipe = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
container.style.cursor = 'default';
|
||||
};
|
||||
|
||||
this.swipeHandle.addEventListener('mousedown', (e) => {
|
||||
this.isDraggingSwipe = true;
|
||||
startX = e.clientX;
|
||||
startPosition = this.currentComparison?.config.swipePosition || 50;
|
||||
container.style.cursor = 'ew-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
// Also allow dragging anywhere in the container
|
||||
container.addEventListener('mousedown', (e) => {
|
||||
if (e.target === this.swipeHandle || (e.target as HTMLElement).parentElement === this.swipeHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = (x / rect.width) * 100;
|
||||
|
||||
clipContainer.style.width = `${percentage}%`;
|
||||
if (this.swipeHandle) {
|
||||
this.swipeHandle.style.left = `${percentage}%`;
|
||||
}
|
||||
|
||||
if (this.currentComparison) {
|
||||
this.currentComparison.config.swipePosition = percentage;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add opacity control for overlay mode
|
||||
*/
|
||||
private addOpacityControl(container: HTMLElement, overlayImg: HTMLImageElement): void {
|
||||
const control = document.createElement('div');
|
||||
control.className = 'opacity-control';
|
||||
control.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'Opacity:';
|
||||
label.style.color = 'white';
|
||||
label.style.fontSize = '12px';
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
slider.value = String((this.currentComparison?.config.opacity || 0.5) * 100);
|
||||
slider.style.width = '150px';
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.textContent = `${slider.value}%`;
|
||||
value.style.color = 'white';
|
||||
value.style.fontSize = '12px';
|
||||
value.style.minWidth = '35px';
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
const opacity = parseInt(slider.value) / 100;
|
||||
overlayImg.style.opacity = String(opacity);
|
||||
value.textContent = `${slider.value}%`;
|
||||
|
||||
if (this.currentComparison) {
|
||||
this.currentComparison.config.opacity = opacity;
|
||||
}
|
||||
});
|
||||
|
||||
control.appendChild(label);
|
||||
control.appendChild(slider);
|
||||
control.appendChild(value);
|
||||
container.appendChild(control);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup synchronized controls for side-by-side mode
|
||||
*/
|
||||
private setupSynchronizedControls(leftPanel: HTMLElement, rightPanel: HTMLElement): void {
|
||||
const leftImg = leftPanel.querySelector('img') as HTMLImageElement;
|
||||
const rightImg = rightPanel.querySelector('img') as HTMLImageElement;
|
||||
|
||||
if (!leftImg || !rightImg) return;
|
||||
|
||||
// Synchronize scroll
|
||||
if (this.currentComparison?.config.syncPan) {
|
||||
leftPanel.addEventListener('scroll', () => {
|
||||
rightPanel.scrollLeft = leftPanel.scrollLeft;
|
||||
rightPanel.scrollTop = leftPanel.scrollTop;
|
||||
});
|
||||
|
||||
rightPanel.addEventListener('scroll', () => {
|
||||
leftPanel.scrollLeft = rightPanel.scrollLeft;
|
||||
leftPanel.scrollTop = rightPanel.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
// Synchronize zoom with wheel events
|
||||
if (this.currentComparison?.config.syncZoom) {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
this.currentZoom = Math.max(0.5, Math.min(5, this.currentZoom * delta));
|
||||
|
||||
leftImg.style.transform = `scale(${this.currentZoom})`;
|
||||
rightImg.style.transform = `scale(${this.currentZoom})`;
|
||||
};
|
||||
|
||||
leftPanel.addEventListener('wheel', handleWheel);
|
||||
rightPanel.addEventListener('wheel', handleWheel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comparison controls toolbar
|
||||
*/
|
||||
private addComparisonControls(container: HTMLElement): void {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'comparison-toolbar';
|
||||
toolbar.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
// Mode switcher
|
||||
const modes: ComparisonMode[] = ['side-by-side', 'overlay', 'swipe', 'difference'];
|
||||
modes.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `mode-btn mode-${mode}`;
|
||||
btn.style.cssText = `
|
||||
background: ${this.currentComparison?.config.mode === mode ? '#2196F3' : 'rgba(255,255,255,0.1)'};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
`;
|
||||
btn.textContent = mode.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
if (this.currentComparison && this.currentComparison.container) {
|
||||
this.currentComparison.config.mode = mode;
|
||||
await this.startComparison(
|
||||
this.currentComparison.leftImage,
|
||||
this.currentComparison.rightImage,
|
||||
this.currentComparison.container,
|
||||
this.currentComparison.config
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.style.cssText = `
|
||||
background: rgba(255,0,0,0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.addEventListener('click', () => this.closeComparison());
|
||||
|
||||
toolbar.appendChild(closeBtn);
|
||||
container.appendChild(toolbar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close comparison
|
||||
*/
|
||||
closeComparison(): void {
|
||||
if (this.currentComparison?.container) {
|
||||
this.currentComparison.container.innerHTML = '';
|
||||
}
|
||||
|
||||
this.currentComparison = null;
|
||||
this.comparisonContainer = undefined;
|
||||
this.leftCanvas = undefined;
|
||||
this.rightCanvas = undefined;
|
||||
this.leftContext = undefined;
|
||||
this.rightContext = undefined;
|
||||
this.swipeHandle = undefined;
|
||||
this.isDraggingSwipe = false;
|
||||
this.currentZoom = 1;
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if comparison is active
|
||||
*/
|
||||
isComparisonActive(): boolean {
|
||||
return this.currentComparison?.isActive || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current comparison state
|
||||
*/
|
||||
getComparisonState(): ComparisonState | null {
|
||||
return this.currentComparison;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageComparisonService.getInstance();
|
||||
@@ -1,874 +0,0 @@
|
||||
/**
|
||||
* Basic Image Editor Module for Trilium Notes
|
||||
* Provides non-destructive image editing capabilities
|
||||
*/
|
||||
|
||||
import server from './server.js';
|
||||
import toastService from './toast.js';
|
||||
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
|
||||
|
||||
/**
|
||||
* Edit operation types
|
||||
*/
|
||||
export type EditOperation =
|
||||
| 'rotate'
|
||||
| 'crop'
|
||||
| 'brightness'
|
||||
| 'contrast'
|
||||
| 'saturation'
|
||||
| 'blur'
|
||||
| 'sharpen';
|
||||
|
||||
/**
|
||||
* Edit history entry
|
||||
*/
|
||||
export interface EditHistoryEntry {
|
||||
operation: EditOperation;
|
||||
params: any;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop area definition
|
||||
*/
|
||||
export interface CropArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor state
|
||||
*/
|
||||
interface EditorState {
|
||||
originalImage: HTMLImageElement | null;
|
||||
currentImage: HTMLImageElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D;
|
||||
history: EditHistoryEntry[];
|
||||
historyIndex: number;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter parameters
|
||||
*/
|
||||
export interface FilterParams {
|
||||
brightness?: number; // -100 to 100
|
||||
contrast?: number; // -100 to 100
|
||||
saturation?: number; // -100 to 100
|
||||
blur?: number; // 0 to 20
|
||||
sharpen?: number; // 0 to 100
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageEditorService provides basic image editing capabilities
|
||||
*/
|
||||
class ImageEditorService {
|
||||
private static instance: ImageEditorService;
|
||||
private editorState: EditorState;
|
||||
private tempCanvas: HTMLCanvasElement;
|
||||
private tempContext: CanvasRenderingContext2D;
|
||||
private cropOverlay?: HTMLElement;
|
||||
private cropHandles?: HTMLElement[];
|
||||
private cropArea: CropArea | null = null;
|
||||
private isDraggingCrop: boolean = false;
|
||||
private dragStartX: number = 0;
|
||||
private dragStartY: number = 0;
|
||||
private currentFilters: FilterParams = {};
|
||||
|
||||
// Canvas size limits for security and memory management
|
||||
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
|
||||
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
|
||||
|
||||
private constructor() {
|
||||
// Initialize canvases
|
||||
this.editorState = {
|
||||
originalImage: null,
|
||||
currentImage: null,
|
||||
canvas: document.createElement('canvas'),
|
||||
context: null as any,
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
isEditing: false
|
||||
};
|
||||
|
||||
const ctx = this.editorState.canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
this.editorState.context = ctx;
|
||||
|
||||
this.tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = this.tempCanvas.getContext('2d');
|
||||
if (!tempCtx) {
|
||||
throw new Error('Failed to get temp canvas context');
|
||||
}
|
||||
this.tempContext = tempCtx;
|
||||
}
|
||||
|
||||
static getInstance(): ImageEditorService {
|
||||
if (!ImageEditorService.instance) {
|
||||
ImageEditorService.instance = new ImageEditorService();
|
||||
}
|
||||
return ImageEditorService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing an image
|
||||
*/
|
||||
async startEditing(src: string | HTMLImageElement): Promise<HTMLCanvasElement> {
|
||||
return await withErrorBoundary(async () => {
|
||||
// Validate input
|
||||
if (typeof src === 'string') {
|
||||
ImageValidator.validateUrl(src);
|
||||
}
|
||||
// Load image
|
||||
let img: HTMLImageElement;
|
||||
if (typeof src === 'string') {
|
||||
img = await this.loadImage(src);
|
||||
} else {
|
||||
img = src;
|
||||
}
|
||||
|
||||
// Validate image dimensions
|
||||
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
|
||||
|
||||
// Check memory availability
|
||||
const estimatedMemory = MemoryMonitor.estimateImageMemory(img.naturalWidth, img.naturalHeight);
|
||||
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.MEMORY_ERROR,
|
||||
'Insufficient memory to process image',
|
||||
{ estimatedMemory }
|
||||
);
|
||||
}
|
||||
|
||||
if (img.naturalWidth > this.MAX_CANVAS_SIZE ||
|
||||
img.naturalHeight > this.MAX_CANVAS_SIZE ||
|
||||
img.naturalWidth * img.naturalHeight > this.MAX_CANVAS_AREA) {
|
||||
|
||||
// Scale down if too large
|
||||
const scale = Math.min(
|
||||
this.MAX_CANVAS_SIZE / Math.max(img.naturalWidth, img.naturalHeight),
|
||||
Math.sqrt(this.MAX_CANVAS_AREA / (img.naturalWidth * img.naturalHeight))
|
||||
);
|
||||
|
||||
const scaledWidth = Math.floor(img.naturalWidth * scale);
|
||||
const scaledHeight = Math.floor(img.naturalHeight * scale);
|
||||
|
||||
console.warn(`Image too large (${img.naturalWidth}x${img.naturalHeight}), scaling to ${scaledWidth}x${scaledHeight}`);
|
||||
|
||||
// Create scaled image
|
||||
const scaledCanvas = document.createElement('canvas');
|
||||
scaledCanvas.width = scaledWidth;
|
||||
scaledCanvas.height = scaledHeight;
|
||||
const scaledCtx = scaledCanvas.getContext('2d');
|
||||
if (!scaledCtx) throw new Error('Failed to get scaled canvas context');
|
||||
|
||||
scaledCtx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||
|
||||
// Create new image from scaled canvas
|
||||
const scaledImg = new Image();
|
||||
scaledImg.src = scaledCanvas.toDataURL();
|
||||
await new Promise(resolve => scaledImg.onload = resolve);
|
||||
img = scaledImg;
|
||||
|
||||
// Clean up scaled canvas
|
||||
scaledCanvas.width = 0;
|
||||
scaledCanvas.height = 0;
|
||||
}
|
||||
|
||||
// Store original
|
||||
this.editorState.originalImage = img;
|
||||
this.editorState.currentImage = img;
|
||||
this.editorState.isEditing = true;
|
||||
this.editorState.history = [];
|
||||
this.editorState.historyIndex = -1;
|
||||
this.currentFilters = {};
|
||||
|
||||
// Setup canvas with validated dimensions
|
||||
this.editorState.canvas.width = img.naturalWidth;
|
||||
this.editorState.canvas.height = img.naturalHeight;
|
||||
this.editorState.context.drawImage(img, 0, 0);
|
||||
|
||||
return this.editorState.canvas;
|
||||
}, (error) => {
|
||||
this.stopEditing();
|
||||
throw error;
|
||||
}) || this.editorState.canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate image by degrees (90, 180, 270)
|
||||
*/
|
||||
rotate(degrees: 90 | 180 | 270 | -90): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
const { canvas, context } = this.editorState;
|
||||
const { width, height } = canvas;
|
||||
|
||||
// Setup temp canvas
|
||||
if (degrees === 90 || degrees === -90 || degrees === 270) {
|
||||
this.tempCanvas.width = height;
|
||||
this.tempCanvas.height = width;
|
||||
} else {
|
||||
this.tempCanvas.width = width;
|
||||
this.tempCanvas.height = height;
|
||||
}
|
||||
|
||||
// Clear temp canvas
|
||||
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
|
||||
|
||||
// Rotate
|
||||
this.tempContext.save();
|
||||
|
||||
if (degrees === 90) {
|
||||
this.tempContext.translate(height, 0);
|
||||
this.tempContext.rotate(Math.PI / 2);
|
||||
} else if (degrees === 180) {
|
||||
this.tempContext.translate(width, height);
|
||||
this.tempContext.rotate(Math.PI);
|
||||
} else if (degrees === 270 || degrees === -90) {
|
||||
this.tempContext.translate(0, width);
|
||||
this.tempContext.rotate(-Math.PI / 2);
|
||||
}
|
||||
|
||||
this.tempContext.drawImage(canvas, 0, 0);
|
||||
this.tempContext.restore();
|
||||
|
||||
// Copy back to main canvas
|
||||
canvas.width = this.tempCanvas.width;
|
||||
canvas.height = this.tempCanvas.height;
|
||||
context.drawImage(this.tempCanvas, 0, 0);
|
||||
|
||||
// Add to history
|
||||
this.addToHistory('rotate', { degrees });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start crop selection
|
||||
*/
|
||||
startCrop(container: HTMLElement): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
// Create crop overlay
|
||||
this.cropOverlay = document.createElement('div');
|
||||
this.cropOverlay.className = 'crop-overlay';
|
||||
this.cropOverlay.style.cssText = `
|
||||
position: absolute;
|
||||
border: 2px dashed #fff;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
cursor: move;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Create resize handles
|
||||
this.cropHandles = [];
|
||||
const handlePositions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
||||
|
||||
handlePositions.forEach(pos => {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = `crop-handle crop-handle-${pos}`;
|
||||
handle.dataset.position = pos;
|
||||
handle.style.cssText = `
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 1px solid #333;
|
||||
z-index: 1001;
|
||||
`;
|
||||
|
||||
// Position handles
|
||||
switch (pos) {
|
||||
case 'nw':
|
||||
handle.style.top = '-5px';
|
||||
handle.style.left = '-5px';
|
||||
handle.style.cursor = 'nw-resize';
|
||||
break;
|
||||
case 'n':
|
||||
handle.style.top = '-5px';
|
||||
handle.style.left = '50%';
|
||||
handle.style.transform = 'translateX(-50%)';
|
||||
handle.style.cursor = 'n-resize';
|
||||
break;
|
||||
case 'ne':
|
||||
handle.style.top = '-5px';
|
||||
handle.style.right = '-5px';
|
||||
handle.style.cursor = 'ne-resize';
|
||||
break;
|
||||
case 'e':
|
||||
handle.style.top = '50%';
|
||||
handle.style.right = '-5px';
|
||||
handle.style.transform = 'translateY(-50%)';
|
||||
handle.style.cursor = 'e-resize';
|
||||
break;
|
||||
case 'se':
|
||||
handle.style.bottom = '-5px';
|
||||
handle.style.right = '-5px';
|
||||
handle.style.cursor = 'se-resize';
|
||||
break;
|
||||
case 's':
|
||||
handle.style.bottom = '-5px';
|
||||
handle.style.left = '50%';
|
||||
handle.style.transform = 'translateX(-50%)';
|
||||
handle.style.cursor = 's-resize';
|
||||
break;
|
||||
case 'sw':
|
||||
handle.style.bottom = '-5px';
|
||||
handle.style.left = '-5px';
|
||||
handle.style.cursor = 'sw-resize';
|
||||
break;
|
||||
case 'w':
|
||||
handle.style.top = '50%';
|
||||
handle.style.left = '-5px';
|
||||
handle.style.transform = 'translateY(-50%)';
|
||||
handle.style.cursor = 'w-resize';
|
||||
break;
|
||||
}
|
||||
|
||||
this.cropOverlay.appendChild(handle);
|
||||
this.cropHandles!.push(handle);
|
||||
});
|
||||
|
||||
// Set initial crop area (80% of image)
|
||||
const canvasRect = this.editorState.canvas.getBoundingClientRect();
|
||||
const initialSize = Math.min(canvasRect.width, canvasRect.height) * 0.8;
|
||||
const initialX = (canvasRect.width - initialSize) / 2;
|
||||
const initialY = (canvasRect.height - initialSize) / 2;
|
||||
|
||||
this.cropArea = {
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
width: initialSize,
|
||||
height: initialSize
|
||||
};
|
||||
|
||||
this.updateCropOverlay();
|
||||
container.appendChild(this.cropOverlay);
|
||||
|
||||
// Setup drag handlers
|
||||
this.setupCropHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup crop interaction handlers
|
||||
*/
|
||||
private setupCropHandlers(): void {
|
||||
if (!this.cropOverlay) return;
|
||||
|
||||
// Drag to move
|
||||
this.cropOverlay.addEventListener('mousedown', (e) => {
|
||||
if ((e.target as HTMLElement).classList.contains('crop-handle')) return;
|
||||
|
||||
this.isDraggingCrop = true;
|
||||
this.dragStartX = e.clientX;
|
||||
this.dragStartY = e.clientY;
|
||||
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!this.isDraggingCrop || !this.cropArea) return;
|
||||
|
||||
const deltaX = e.clientX - this.dragStartX;
|
||||
const deltaY = e.clientY - this.dragStartY;
|
||||
|
||||
this.cropArea.x += deltaX;
|
||||
this.cropArea.y += deltaY;
|
||||
|
||||
this.dragStartX = e.clientX;
|
||||
this.dragStartY = e.clientY;
|
||||
|
||||
this.updateCropOverlay();
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
this.isDraggingCrop = false;
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
});
|
||||
|
||||
// Resize handles
|
||||
this.cropHandles?.forEach(handle => {
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const position = handle.dataset.position!;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startCrop = { ...this.cropArea! };
|
||||
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!this.cropArea) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
switch (position) {
|
||||
case 'nw':
|
||||
this.cropArea.x = startCrop.x + deltaX;
|
||||
this.cropArea.y = startCrop.y + deltaY;
|
||||
this.cropArea.width = startCrop.width - deltaX;
|
||||
this.cropArea.height = startCrop.height - deltaY;
|
||||
break;
|
||||
case 'n':
|
||||
this.cropArea.y = startCrop.y + deltaY;
|
||||
this.cropArea.height = startCrop.height - deltaY;
|
||||
break;
|
||||
case 'ne':
|
||||
this.cropArea.y = startCrop.y + deltaY;
|
||||
this.cropArea.width = startCrop.width + deltaX;
|
||||
this.cropArea.height = startCrop.height - deltaY;
|
||||
break;
|
||||
case 'e':
|
||||
this.cropArea.width = startCrop.width + deltaX;
|
||||
break;
|
||||
case 'se':
|
||||
this.cropArea.width = startCrop.width + deltaX;
|
||||
this.cropArea.height = startCrop.height + deltaY;
|
||||
break;
|
||||
case 's':
|
||||
this.cropArea.height = startCrop.height + deltaY;
|
||||
break;
|
||||
case 'sw':
|
||||
this.cropArea.x = startCrop.x + deltaX;
|
||||
this.cropArea.width = startCrop.width - deltaX;
|
||||
this.cropArea.height = startCrop.height + deltaY;
|
||||
break;
|
||||
case 'w':
|
||||
this.cropArea.x = startCrop.x + deltaX;
|
||||
this.cropArea.width = startCrop.width - deltaX;
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure minimum size
|
||||
this.cropArea.width = Math.max(50, this.cropArea.width);
|
||||
this.cropArea.height = Math.max(50, this.cropArea.height);
|
||||
|
||||
this.updateCropOverlay();
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update crop overlay position
|
||||
*/
|
||||
private updateCropOverlay(): void {
|
||||
if (!this.cropOverlay || !this.cropArea) return;
|
||||
|
||||
this.cropOverlay.style.left = `${this.cropArea.x}px`;
|
||||
this.cropOverlay.style.top = `${this.cropArea.y}px`;
|
||||
this.cropOverlay.style.width = `${this.cropArea.width}px`;
|
||||
this.cropOverlay.style.height = `${this.cropArea.height}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply crop
|
||||
*/
|
||||
applyCrop(): void {
|
||||
if (!this.editorState.isEditing || !this.cropArea) return;
|
||||
|
||||
const { canvas, context } = this.editorState;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
|
||||
// Convert crop area from screen to canvas coordinates
|
||||
const scaleX = canvas.width / canvasRect.width;
|
||||
const scaleY = canvas.height / canvasRect.height;
|
||||
|
||||
const cropX = this.cropArea.x * scaleX;
|
||||
const cropY = this.cropArea.y * scaleY;
|
||||
const cropWidth = this.cropArea.width * scaleX;
|
||||
const cropHeight = this.cropArea.height * scaleY;
|
||||
|
||||
// Get cropped image data
|
||||
const imageData = context.getImageData(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
// Resize canvas and put cropped image
|
||||
canvas.width = cropWidth;
|
||||
canvas.height = cropHeight;
|
||||
context.putImageData(imageData, 0, 0);
|
||||
|
||||
// Clean up crop overlay
|
||||
this.cancelCrop();
|
||||
|
||||
// Add to history
|
||||
this.addToHistory('crop', {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel crop
|
||||
*/
|
||||
cancelCrop(): void {
|
||||
if (this.cropOverlay) {
|
||||
this.cropOverlay.remove();
|
||||
this.cropOverlay = undefined;
|
||||
}
|
||||
this.cropHandles = undefined;
|
||||
this.cropArea = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply brightness adjustment
|
||||
*/
|
||||
applyBrightness(value: number): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
this.currentFilters.brightness = value;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply contrast adjustment
|
||||
*/
|
||||
applyContrast(value: number): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
this.currentFilters.contrast = value;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saturation adjustment
|
||||
*/
|
||||
applySaturation(value: number): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
this.currentFilters.saturation = value;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all filters
|
||||
*/
|
||||
private applyFilters(): void {
|
||||
const { canvas, context, originalImage } = this.editorState;
|
||||
|
||||
if (!originalImage) return;
|
||||
|
||||
// Clear canvas and redraw original
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get image data
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Apply brightness
|
||||
if (this.currentFilters.brightness) {
|
||||
const brightness = this.currentFilters.brightness * 2.55; // Convert to 0-255 range
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = Math.min(255, Math.max(0, data[i] + brightness));
|
||||
data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + brightness));
|
||||
data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + brightness));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply contrast
|
||||
if (this.currentFilters.contrast) {
|
||||
const factor = (259 * (this.currentFilters.contrast + 255)) / (255 * (259 - this.currentFilters.contrast));
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
|
||||
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
|
||||
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saturation
|
||||
if (this.currentFilters.saturation) {
|
||||
const saturation = this.currentFilters.saturation / 100;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const gray = 0.2989 * data[i] + 0.5870 * data[i + 1] + 0.1140 * data[i + 2];
|
||||
data[i] = Math.min(255, Math.max(0, gray + saturation * (data[i] - gray)));
|
||||
data[i + 1] = Math.min(255, Math.max(0, gray + saturation * (data[i + 1] - gray)));
|
||||
data[i + 2] = Math.min(255, Math.max(0, gray + saturation * (data[i + 2] - gray)));
|
||||
}
|
||||
}
|
||||
|
||||
// Put modified image data back
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply blur effect
|
||||
*/
|
||||
applyBlur(radius: number): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
const { canvas, context } = this.editorState;
|
||||
|
||||
// Use CSS filter for performance
|
||||
context.filter = `blur(${radius}px)`;
|
||||
context.drawImage(canvas, 0, 0);
|
||||
context.filter = 'none';
|
||||
|
||||
this.addToHistory('blur', { radius });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sharpen effect
|
||||
*/
|
||||
applySharpen(amount: number): void {
|
||||
if (!this.editorState.isEditing) return;
|
||||
|
||||
const { canvas, context } = this.editorState;
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Create copy of original data
|
||||
const original = new Uint8ClampedArray(data);
|
||||
|
||||
// Sharpen kernel
|
||||
const kernel = [
|
||||
0, -1, 0,
|
||||
-1, 5 + amount / 25, -1,
|
||||
0, -1, 0
|
||||
];
|
||||
|
||||
// Apply convolution
|
||||
for (let y = 1; y < height - 1; y++) {
|
||||
for (let x = 1; x < width - 1; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
|
||||
for (let c = 0; c < 3; c++) {
|
||||
let sum = 0;
|
||||
for (let ky = -1; ky <= 1; ky++) {
|
||||
for (let kx = -1; kx <= 1; kx++) {
|
||||
const kidx = ((y + ky) * width + (x + kx)) * 4;
|
||||
sum += original[kidx + c] * kernel[(ky + 1) * 3 + (kx + 1)];
|
||||
}
|
||||
}
|
||||
data[idx + c] = Math.min(255, Math.max(0, sum));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
this.addToHistory('sharpen', { amount });
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last operation
|
||||
*/
|
||||
undo(): void {
|
||||
if (!this.editorState.isEditing || this.editorState.historyIndex <= 0) return;
|
||||
|
||||
this.editorState.historyIndex--;
|
||||
this.replayHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo operation
|
||||
*/
|
||||
redo(): void {
|
||||
if (!this.editorState.isEditing ||
|
||||
this.editorState.historyIndex >= this.editorState.history.length - 1) return;
|
||||
|
||||
this.editorState.historyIndex++;
|
||||
this.replayHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay history up to current index
|
||||
*/
|
||||
private replayHistory(): void {
|
||||
const { canvas, context, originalImage, history, historyIndex } = this.editorState;
|
||||
|
||||
if (!originalImage) return;
|
||||
|
||||
// Reset to original
|
||||
canvas.width = originalImage.naturalWidth;
|
||||
canvas.height = originalImage.naturalHeight;
|
||||
context.drawImage(originalImage, 0, 0);
|
||||
|
||||
// Replay operations
|
||||
for (let i = 0; i <= historyIndex; i++) {
|
||||
const entry = history[i];
|
||||
// Apply operation based on entry
|
||||
// Note: This is simplified - actual implementation would need to store and replay exact operations
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add operation to history
|
||||
*/
|
||||
private addToHistory(operation: EditOperation, params: any): void {
|
||||
// Remove any operations after current index
|
||||
this.editorState.history = this.editorState.history.slice(0, this.editorState.historyIndex + 1);
|
||||
|
||||
// Add new operation
|
||||
this.editorState.history.push({
|
||||
operation,
|
||||
params,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
this.editorState.historyIndex++;
|
||||
|
||||
// Limit history size
|
||||
if (this.editorState.history.length > 50) {
|
||||
this.editorState.history.shift();
|
||||
this.editorState.historyIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited image
|
||||
*/
|
||||
async saveImage(noteId?: string): Promise<Blob> {
|
||||
if (!this.editorState.isEditing) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'No image being edited'
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.editorState.canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
|
||||
if (noteId) {
|
||||
// Optionally save to server
|
||||
this.saveToServer(noteId, blob);
|
||||
}
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited image to server
|
||||
*/
|
||||
private async saveToServer(noteId: string, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, 'edited.png');
|
||||
|
||||
await server.upload(`notes/${noteId}/image`, formData);
|
||||
toastService.showMessage('Image saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save image:', error);
|
||||
toastService.showError('Failed to save image');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to original image
|
||||
*/
|
||||
reset(): void {
|
||||
if (!this.editorState.isEditing || !this.editorState.originalImage) return;
|
||||
|
||||
const { canvas, context, originalImage } = this.editorState;
|
||||
|
||||
canvas.width = originalImage.naturalWidth;
|
||||
canvas.height = originalImage.naturalHeight;
|
||||
context.drawImage(originalImage, 0, 0);
|
||||
|
||||
this.currentFilters = {};
|
||||
this.editorState.history = [];
|
||||
this.editorState.historyIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop editing and clean up resources
|
||||
*/
|
||||
stopEditing(): void {
|
||||
this.cancelCrop();
|
||||
|
||||
// Request garbage collection after cleanup
|
||||
MemoryMonitor.requestGarbageCollection();
|
||||
|
||||
// Clean up canvas memory
|
||||
if (this.editorState.canvas) {
|
||||
this.editorState.context.clearRect(0, 0, this.editorState.canvas.width, this.editorState.canvas.height);
|
||||
this.editorState.canvas.width = 0;
|
||||
this.editorState.canvas.height = 0;
|
||||
}
|
||||
|
||||
if (this.tempCanvas) {
|
||||
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
|
||||
this.tempCanvas.width = 0;
|
||||
this.tempCanvas.height = 0;
|
||||
}
|
||||
|
||||
// Release image references
|
||||
if (this.editorState.originalImage) {
|
||||
this.editorState.originalImage.src = '';
|
||||
}
|
||||
if (this.editorState.currentImage) {
|
||||
this.editorState.currentImage.src = '';
|
||||
}
|
||||
|
||||
this.editorState.isEditing = false;
|
||||
this.editorState.originalImage = null;
|
||||
this.editorState.currentImage = null;
|
||||
this.editorState.history = [];
|
||||
this.editorState.historyIndex = -1;
|
||||
this.currentFilters = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from URL
|
||||
*/
|
||||
private loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if can undo
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.editorState.historyIndex > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if can redo
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.editorState.historyIndex < this.editorState.history.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current canvas
|
||||
*/
|
||||
getCanvas(): HTMLCanvasElement {
|
||||
return this.editorState.canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if editing
|
||||
*/
|
||||
isEditing(): boolean {
|
||||
return this.editorState.isEditing;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageEditorService.getInstance();
|
||||
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* Error Handler for Image Processing Operations
|
||||
* Provides error boundaries and validation for image-related operations
|
||||
*/
|
||||
|
||||
import toastService from './toast.js';
|
||||
|
||||
/**
|
||||
* Error types for image operations
|
||||
*/
|
||||
export enum ImageErrorType {
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
SIZE_LIMIT_EXCEEDED = 'SIZE_LIMIT_EXCEEDED',
|
||||
MEMORY_ERROR = 'MEMORY_ERROR',
|
||||
PROCESSING_ERROR = 'PROCESSING_ERROR',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
SECURITY_ERROR = 'SECURITY_ERROR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for image operations
|
||||
*/
|
||||
export class ImageError extends Error {
|
||||
constructor(
|
||||
public type: ImageErrorType,
|
||||
message: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ImageError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input validation utilities
|
||||
*/
|
||||
export class ImageValidator {
|
||||
private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
private static readonly ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp'
|
||||
];
|
||||
private static readonly MAX_DIMENSION = 16384;
|
||||
private static readonly MAX_AREA = 100000000; // 100 megapixels
|
||||
|
||||
/**
|
||||
* Validate file input
|
||||
*/
|
||||
static validateFile(file: File): void {
|
||||
// Check file size
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.SIZE_LIMIT_EXCEEDED,
|
||||
`File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
{ fileSize: file.size, maxSize: this.MAX_FILE_SIZE }
|
||||
);
|
||||
}
|
||||
|
||||
// Check MIME type
|
||||
if (!this.ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
`File type ${file.type} is not supported`,
|
||||
{ fileType: file.type, allowedTypes: this.ALLOWED_MIME_TYPES }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image dimensions
|
||||
*/
|
||||
static validateDimensions(width: number, height: number): void {
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'Invalid image dimensions',
|
||||
{ width, height }
|
||||
);
|
||||
}
|
||||
|
||||
if (width > this.MAX_DIMENSION || height > this.MAX_DIMENSION) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.SIZE_LIMIT_EXCEEDED,
|
||||
`Image dimensions exceed maximum allowed size of ${this.MAX_DIMENSION}px`,
|
||||
{ width, height, maxDimension: this.MAX_DIMENSION }
|
||||
);
|
||||
}
|
||||
|
||||
if (width * height > this.MAX_AREA) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.SIZE_LIMIT_EXCEEDED,
|
||||
`Image area exceeds maximum allowed area of ${this.MAX_AREA / 1000000} megapixels`,
|
||||
{ area: width * height, maxArea: this.MAX_AREA }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL
|
||||
*/
|
||||
static validateUrl(url: string): void {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Check protocol
|
||||
if (!['http:', 'https:', 'data:', 'blob:'].includes(parsedUrl.protocol)) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.SECURITY_ERROR,
|
||||
`Unsupported protocol: ${parsedUrl.protocol}`,
|
||||
{ url, protocol: parsedUrl.protocol }
|
||||
);
|
||||
}
|
||||
|
||||
// Additional security checks for data URLs
|
||||
if (parsedUrl.protocol === 'data:') {
|
||||
const [header] = url.split(',');
|
||||
if (!header.includes('image/')) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'Data URL does not contain image data',
|
||||
{ url: url.substring(0, 100) }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ImageError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ImageError(
|
||||
ImageErrorType.INVALID_INPUT,
|
||||
'Invalid URL format',
|
||||
{ url, originalError: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename
|
||||
*/
|
||||
static sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts
|
||||
filename = filename.replace(/\.\./g, '');
|
||||
filename = filename.replace(/[\/\\]/g, '_');
|
||||
|
||||
// Remove special characters except dots and dashes
|
||||
filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
|
||||
// Limit length
|
||||
if (filename.length > 255) {
|
||||
const ext = filename.split('.').pop();
|
||||
filename = filename.substring(0, 250) + '.' + ext;
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary wrapper for async operations
|
||||
*/
|
||||
export async function withErrorBoundary<T>(
|
||||
operation: () => Promise<T>,
|
||||
errorHandler?: (error: Error) => void
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
const imageError = error instanceof ImageError
|
||||
? error
|
||||
: new ImageError(
|
||||
ImageErrorType.PROCESSING_ERROR,
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{ originalError: error }
|
||||
);
|
||||
|
||||
// Log error
|
||||
console.error('[Image Error]', imageError.type, imageError.message, imageError.details);
|
||||
|
||||
// Show user-friendly message
|
||||
switch (imageError.type) {
|
||||
case ImageErrorType.SIZE_LIMIT_EXCEEDED:
|
||||
toastService.showError('Image is too large to process');
|
||||
break;
|
||||
case ImageErrorType.INVALID_INPUT:
|
||||
toastService.showError('Invalid image or input provided');
|
||||
break;
|
||||
case ImageErrorType.MEMORY_ERROR:
|
||||
toastService.showError('Not enough memory to process image');
|
||||
break;
|
||||
case ImageErrorType.SECURITY_ERROR:
|
||||
toastService.showError('Security violation detected');
|
||||
break;
|
||||
case ImageErrorType.NETWORK_ERROR:
|
||||
toastService.showError('Network error occurred');
|
||||
break;
|
||||
default:
|
||||
toastService.showError('Failed to process image');
|
||||
}
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (errorHandler) {
|
||||
errorHandler(imageError);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory monitoring utilities
|
||||
*/
|
||||
export class MemoryMonitor {
|
||||
private static readonly WARNING_THRESHOLD = 0.8; // 80% of available memory
|
||||
|
||||
/**
|
||||
* Check if memory is available for operation
|
||||
*/
|
||||
static checkMemoryAvailable(estimatedBytes: number): boolean {
|
||||
if ('memory' in performance && (performance as any).memory) {
|
||||
const memory = (performance as any).memory;
|
||||
const used = memory.usedJSHeapSize;
|
||||
const limit = memory.jsHeapSizeLimit;
|
||||
const available = limit - used;
|
||||
|
||||
if (estimatedBytes > available * this.WARNING_THRESHOLD) {
|
||||
console.warn(`Memory warning: Estimated ${estimatedBytes} bytes needed, ${available} bytes available`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory needed for image
|
||||
*/
|
||||
static estimateImageMemory(width: number, height: number, channels: number = 4): number {
|
||||
// Each pixel uses 4 bytes (RGBA) or specified channels
|
||||
return width * height * channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force garbage collection if available
|
||||
*/
|
||||
static requestGarbageCollection(): void {
|
||||
if (typeof (globalThis as any).gc === 'function') {
|
||||
(globalThis as any).gc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Worker support for heavy operations
|
||||
*/
|
||||
export class ImageWorkerPool {
|
||||
private workers: Worker[] = [];
|
||||
private taskQueue: Array<{
|
||||
data: any;
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: any) => void;
|
||||
}> = [];
|
||||
private busyWorkers = new Set<Worker>();
|
||||
|
||||
constructor(
|
||||
private workerScript: string,
|
||||
private poolSize: number = navigator.hardwareConcurrency || 4
|
||||
) {
|
||||
this.initializeWorkers();
|
||||
}
|
||||
|
||||
private initializeWorkers(): void {
|
||||
for (let i = 0; i < this.poolSize; i++) {
|
||||
try {
|
||||
const worker = new Worker(this.workerScript);
|
||||
worker.addEventListener('message', (e) => this.handleWorkerMessage(worker, e));
|
||||
worker.addEventListener('error', (e) => this.handleWorkerError(worker, e));
|
||||
this.workers.push(worker);
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerMessage(worker: Worker, event: MessageEvent): void {
|
||||
this.busyWorkers.delete(worker);
|
||||
|
||||
// Process next task if available
|
||||
if (this.taskQueue.length > 0) {
|
||||
const task = this.taskQueue.shift()!;
|
||||
this.executeTask(worker, task);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerError(worker: Worker, event: ErrorEvent): void {
|
||||
this.busyWorkers.delete(worker);
|
||||
console.error('Worker error:', event);
|
||||
}
|
||||
|
||||
private executeTask(
|
||||
worker: Worker,
|
||||
task: { data: any; resolve: (value: any) => void; reject: (error: any) => void }
|
||||
): void {
|
||||
this.busyWorkers.add(worker);
|
||||
|
||||
const messageHandler = (e: MessageEvent) => {
|
||||
worker.removeEventListener('message', messageHandler);
|
||||
worker.removeEventListener('error', errorHandler);
|
||||
this.busyWorkers.delete(worker);
|
||||
task.resolve(e.data);
|
||||
|
||||
// Process next task
|
||||
if (this.taskQueue.length > 0) {
|
||||
const nextTask = this.taskQueue.shift()!;
|
||||
this.executeTask(worker, nextTask);
|
||||
}
|
||||
};
|
||||
|
||||
const errorHandler = (e: ErrorEvent) => {
|
||||
worker.removeEventListener('message', messageHandler);
|
||||
worker.removeEventListener('error', errorHandler);
|
||||
this.busyWorkers.delete(worker);
|
||||
task.reject(e);
|
||||
|
||||
// Process next task
|
||||
if (this.taskQueue.length > 0) {
|
||||
const nextTask = this.taskQueue.shift()!;
|
||||
this.executeTask(worker, nextTask);
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', messageHandler);
|
||||
worker.addEventListener('error', errorHandler);
|
||||
worker.postMessage(task.data);
|
||||
}
|
||||
|
||||
async process(data: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Find available worker
|
||||
const availableWorker = this.workers.find(w => !this.busyWorkers.has(w));
|
||||
|
||||
if (availableWorker) {
|
||||
this.executeTask(availableWorker, { data, resolve, reject });
|
||||
} else {
|
||||
// Queue task
|
||||
this.taskQueue.push({ data, resolve, reject });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.workers.forEach(worker => worker.terminate());
|
||||
this.workers = [];
|
||||
this.taskQueue = [];
|
||||
this.busyWorkers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
ImageError,
|
||||
ImageErrorType,
|
||||
ImageValidator,
|
||||
MemoryMonitor,
|
||||
ImageWorkerPool,
|
||||
withErrorBoundary
|
||||
};
|
||||
@@ -1,839 +0,0 @@
|
||||
/**
|
||||
* EXIF Data Viewer Module for Trilium Notes
|
||||
* Extracts and displays EXIF metadata from images
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXIF data structure
|
||||
*/
|
||||
export interface ExifData {
|
||||
// Image information
|
||||
make?: string;
|
||||
model?: string;
|
||||
software?: string;
|
||||
dateTime?: Date;
|
||||
dateTimeOriginal?: Date;
|
||||
dateTimeDigitized?: Date;
|
||||
|
||||
// Camera settings
|
||||
exposureTime?: string;
|
||||
fNumber?: number;
|
||||
exposureProgram?: string;
|
||||
iso?: number;
|
||||
shutterSpeedValue?: string;
|
||||
apertureValue?: number;
|
||||
brightnessValue?: number;
|
||||
exposureBiasValue?: number;
|
||||
maxApertureValue?: number;
|
||||
meteringMode?: string;
|
||||
flash?: string;
|
||||
focalLength?: number;
|
||||
focalLengthIn35mm?: number;
|
||||
|
||||
// Image properties
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
orientation?: number;
|
||||
xResolution?: number;
|
||||
yResolution?: number;
|
||||
resolutionUnit?: string;
|
||||
colorSpace?: string;
|
||||
whiteBalance?: string;
|
||||
|
||||
// GPS information
|
||||
gpsLatitude?: number;
|
||||
gpsLongitude?: number;
|
||||
gpsAltitude?: number;
|
||||
gpsTimestamp?: Date;
|
||||
gpsSpeed?: number;
|
||||
gpsDirection?: number;
|
||||
|
||||
// Other metadata
|
||||
artist?: string;
|
||||
copyright?: string;
|
||||
userComment?: string;
|
||||
imageDescription?: string;
|
||||
lensModel?: string;
|
||||
lensMake?: string;
|
||||
|
||||
// Raw data
|
||||
raw?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF tag definitions
|
||||
*/
|
||||
const EXIF_TAGS: Record<number, string> = {
|
||||
0x010F: 'make',
|
||||
0x0110: 'model',
|
||||
0x0131: 'software',
|
||||
0x0132: 'dateTime',
|
||||
0x829A: 'exposureTime',
|
||||
0x829D: 'fNumber',
|
||||
0x8822: 'exposureProgram',
|
||||
0x8827: 'iso',
|
||||
0x9003: 'dateTimeOriginal',
|
||||
0x9004: 'dateTimeDigitized',
|
||||
0x9201: 'shutterSpeedValue',
|
||||
0x9202: 'apertureValue',
|
||||
0x9203: 'brightnessValue',
|
||||
0x9204: 'exposureBiasValue',
|
||||
0x9205: 'maxApertureValue',
|
||||
0x9207: 'meteringMode',
|
||||
0x9209: 'flash',
|
||||
0x920A: 'focalLength',
|
||||
0xA002: 'imageWidth',
|
||||
0xA003: 'imageHeight',
|
||||
0x0112: 'orientation',
|
||||
0x011A: 'xResolution',
|
||||
0x011B: 'yResolution',
|
||||
0x0128: 'resolutionUnit',
|
||||
0xA001: 'colorSpace',
|
||||
0xA403: 'whiteBalance',
|
||||
0x8298: 'copyright',
|
||||
0x013B: 'artist',
|
||||
0x9286: 'userComment',
|
||||
0x010E: 'imageDescription',
|
||||
0xA434: 'lensModel',
|
||||
0xA433: 'lensMake',
|
||||
0xA432: 'focalLengthIn35mm'
|
||||
};
|
||||
|
||||
/**
|
||||
* GPS tag definitions
|
||||
*/
|
||||
const GPS_TAGS: Record<number, string> = {
|
||||
0x0001: 'gpsLatitudeRef',
|
||||
0x0002: 'gpsLatitude',
|
||||
0x0003: 'gpsLongitudeRef',
|
||||
0x0004: 'gpsLongitude',
|
||||
0x0005: 'gpsAltitudeRef',
|
||||
0x0006: 'gpsAltitude',
|
||||
0x0007: 'gpsTimestamp',
|
||||
0x000D: 'gpsSpeed',
|
||||
0x0010: 'gpsDirection'
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageExifService extracts and manages EXIF metadata from images
|
||||
*/
|
||||
class ImageExifService {
|
||||
private static instance: ImageExifService;
|
||||
private exifCache: Map<string, ExifData> = new Map();
|
||||
private cacheOrder: string[] = []; // Track cache insertion order for LRU
|
||||
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached entries
|
||||
private readonly MAX_BUFFER_SIZE = 100 * 1024 * 1024; // 100MB max buffer size
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ImageExifService {
|
||||
if (!ImageExifService.instance) {
|
||||
ImageExifService.instance = new ImageExifService();
|
||||
}
|
||||
return ImageExifService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract EXIF data from image URL or file
|
||||
*/
|
||||
async extractExifData(source: string | File | Blob): Promise<ExifData | null> {
|
||||
try {
|
||||
// Check cache if URL
|
||||
if (typeof source === 'string' && this.exifCache.has(source)) {
|
||||
// Move to end for LRU
|
||||
this.updateCacheOrder(source);
|
||||
return this.exifCache.get(source)!;
|
||||
}
|
||||
|
||||
// Get array buffer with size validation
|
||||
const buffer = await this.getArrayBuffer(source);
|
||||
|
||||
// Validate buffer size
|
||||
if (buffer.byteLength > this.MAX_BUFFER_SIZE) {
|
||||
console.error('Buffer size exceeds maximum allowed size');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse EXIF data
|
||||
const exifData = this.parseExifData(buffer);
|
||||
|
||||
// Cache if URL with LRU eviction
|
||||
if (typeof source === 'string' && exifData) {
|
||||
this.addToCache(source, exifData);
|
||||
}
|
||||
|
||||
return exifData;
|
||||
} catch (error) {
|
||||
console.error('Failed to extract EXIF data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array buffer from various sources
|
||||
*/
|
||||
private async getArrayBuffer(source: string | File | Blob): Promise<ArrayBuffer> {
|
||||
if (source instanceof File || source instanceof Blob) {
|
||||
return source.arrayBuffer();
|
||||
} else {
|
||||
const response = await fetch(source);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EXIF data from array buffer
|
||||
*/
|
||||
private parseExifData(buffer: ArrayBuffer): ExifData | null {
|
||||
const dataView = new DataView(buffer);
|
||||
|
||||
// Check for JPEG SOI marker
|
||||
if (dataView.getUint16(0) !== 0xFFD8) {
|
||||
return null; // Not a JPEG
|
||||
}
|
||||
|
||||
// Find APP1 marker (EXIF)
|
||||
let offset = 2;
|
||||
let marker;
|
||||
|
||||
while (offset < dataView.byteLength) {
|
||||
marker = dataView.getUint16(offset);
|
||||
|
||||
if (marker === 0xFFE1) {
|
||||
// Found EXIF marker
|
||||
return this.parseExifSegment(dataView, offset + 2);
|
||||
}
|
||||
|
||||
if ((marker & 0xFF00) !== 0xFF00) {
|
||||
break; // Invalid marker
|
||||
}
|
||||
|
||||
offset += 2 + dataView.getUint16(offset + 2);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EXIF segment with bounds checking
|
||||
*/
|
||||
private parseExifSegment(dataView: DataView, offset: number): ExifData | null {
|
||||
// Bounds check
|
||||
if (offset + 2 > dataView.byteLength) {
|
||||
console.error('Invalid offset for EXIF segment');
|
||||
return null;
|
||||
}
|
||||
|
||||
const length = dataView.getUint16(offset);
|
||||
|
||||
// Validate segment length
|
||||
if (offset + length > dataView.byteLength) {
|
||||
console.error('EXIF segment length exceeds buffer size');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for "Exif\0\0" identifier with bounds check
|
||||
if (offset + 6 > dataView.byteLength) {
|
||||
console.error('Invalid EXIF header offset');
|
||||
return null;
|
||||
}
|
||||
|
||||
const exifHeader = String.fromCharCode(
|
||||
dataView.getUint8(offset + 2),
|
||||
dataView.getUint8(offset + 3),
|
||||
dataView.getUint8(offset + 4),
|
||||
dataView.getUint8(offset + 5)
|
||||
);
|
||||
|
||||
if (exifHeader !== 'Exif') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TIFF header offset
|
||||
const tiffOffset = offset + 8;
|
||||
|
||||
// Check byte order
|
||||
const byteOrder = dataView.getUint16(tiffOffset);
|
||||
const littleEndian = byteOrder === 0x4949; // 'II' for Intel
|
||||
|
||||
if (byteOrder !== 0x4949 && byteOrder !== 0x4D4D) {
|
||||
return null; // Invalid byte order
|
||||
}
|
||||
|
||||
// Parse IFD
|
||||
const ifdOffset = this.getUint32(dataView, tiffOffset + 4, littleEndian);
|
||||
const exifData = this.parseIFD(dataView, tiffOffset, tiffOffset + ifdOffset, littleEndian);
|
||||
|
||||
// Parse GPS data if available
|
||||
if (exifData.raw?.gpsIFDPointer) {
|
||||
const gpsData = this.parseGPSIFD(
|
||||
dataView,
|
||||
tiffOffset,
|
||||
tiffOffset + exifData.raw.gpsIFDPointer,
|
||||
littleEndian
|
||||
);
|
||||
Object.assign(exifData, gpsData);
|
||||
}
|
||||
|
||||
return this.formatExifData(exifData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IFD (Image File Directory) with bounds checking
|
||||
*/
|
||||
private parseIFD(
|
||||
dataView: DataView,
|
||||
tiffOffset: number,
|
||||
ifdOffset: number,
|
||||
littleEndian: boolean
|
||||
): ExifData {
|
||||
// Bounds check for IFD offset
|
||||
if (ifdOffset + 2 > dataView.byteLength) {
|
||||
console.error('Invalid IFD offset');
|
||||
return { raw: {} };
|
||||
}
|
||||
|
||||
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
|
||||
|
||||
// Validate number of entries
|
||||
if (numEntries > 1000) { // Reasonable limit
|
||||
console.error('Too many IFD entries');
|
||||
return { raw: {} };
|
||||
}
|
||||
|
||||
const data: ExifData = { raw: {} };
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = ifdOffset + 2 + (i * 12);
|
||||
|
||||
// Bounds check for entry
|
||||
if (entryOffset + 12 > dataView.byteLength) {
|
||||
console.warn('IFD entry exceeds buffer bounds');
|
||||
break;
|
||||
}
|
||||
|
||||
const tag = this.getUint16(dataView, entryOffset, littleEndian);
|
||||
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
|
||||
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
|
||||
const valueOffset = entryOffset + 8;
|
||||
|
||||
const value = this.getTagValue(
|
||||
dataView,
|
||||
tiffOffset,
|
||||
type,
|
||||
count,
|
||||
valueOffset,
|
||||
littleEndian
|
||||
);
|
||||
|
||||
const tagName = EXIF_TAGS[tag];
|
||||
if (tagName) {
|
||||
(data as any)[tagName] = value;
|
||||
}
|
||||
|
||||
// Store raw value
|
||||
data.raw![tag] = value;
|
||||
|
||||
// Check for EXIF IFD pointer
|
||||
if (tag === 0x8769) {
|
||||
const exifIFDOffset = tiffOffset + value;
|
||||
const exifData = this.parseIFD(dataView, tiffOffset, exifIFDOffset, littleEndian);
|
||||
Object.assign(data, exifData);
|
||||
}
|
||||
|
||||
// Store GPS IFD pointer
|
||||
if (tag === 0x8825) {
|
||||
data.raw!.gpsIFDPointer = value;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GPS IFD
|
||||
*/
|
||||
private parseGPSIFD(
|
||||
dataView: DataView,
|
||||
tiffOffset: number,
|
||||
ifdOffset: number,
|
||||
littleEndian: boolean
|
||||
): Partial<ExifData> {
|
||||
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
|
||||
const gpsData: any = {};
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = ifdOffset + 2 + (i * 12);
|
||||
|
||||
// Bounds check for entry
|
||||
if (entryOffset + 12 > dataView.byteLength) {
|
||||
console.warn('IFD entry exceeds buffer bounds');
|
||||
break;
|
||||
}
|
||||
|
||||
const tag = this.getUint16(dataView, entryOffset, littleEndian);
|
||||
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
|
||||
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
|
||||
const valueOffset = entryOffset + 8;
|
||||
|
||||
const value = this.getTagValue(
|
||||
dataView,
|
||||
tiffOffset,
|
||||
type,
|
||||
count,
|
||||
valueOffset,
|
||||
littleEndian
|
||||
);
|
||||
|
||||
const tagName = GPS_TAGS[tag];
|
||||
if (tagName) {
|
||||
gpsData[tagName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert GPS coordinates
|
||||
const result: Partial<ExifData> = {};
|
||||
|
||||
if (gpsData.gpsLatitude && gpsData.gpsLatitudeRef) {
|
||||
result.gpsLatitude = this.convertGPSCoordinate(
|
||||
gpsData.gpsLatitude,
|
||||
gpsData.gpsLatitudeRef
|
||||
);
|
||||
}
|
||||
|
||||
if (gpsData.gpsLongitude && gpsData.gpsLongitudeRef) {
|
||||
result.gpsLongitude = this.convertGPSCoordinate(
|
||||
gpsData.gpsLongitude,
|
||||
gpsData.gpsLongitudeRef
|
||||
);
|
||||
}
|
||||
|
||||
if (gpsData.gpsAltitude) {
|
||||
result.gpsAltitude = gpsData.gpsAltitude;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value based on type
|
||||
*/
|
||||
private getTagValue(
|
||||
dataView: DataView,
|
||||
tiffOffset: number,
|
||||
type: number,
|
||||
count: number,
|
||||
offset: number,
|
||||
littleEndian: boolean
|
||||
): any {
|
||||
switch (type) {
|
||||
case 1: // BYTE
|
||||
case 7: // UNDEFINED
|
||||
if (count === 1) {
|
||||
return dataView.getUint8(offset);
|
||||
}
|
||||
const bytes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
bytes.push(dataView.getUint8(offset + i));
|
||||
}
|
||||
return bytes;
|
||||
|
||||
case 2: // ASCII
|
||||
const stringOffset = count > 4
|
||||
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
|
||||
: offset;
|
||||
let str = '';
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
const char = dataView.getUint8(stringOffset + i);
|
||||
if (char === 0) break;
|
||||
str += String.fromCharCode(char);
|
||||
}
|
||||
return str;
|
||||
|
||||
case 3: // SHORT
|
||||
if (count === 1) {
|
||||
return this.getUint16(dataView, offset, littleEndian);
|
||||
}
|
||||
const shorts = [];
|
||||
const shortOffset = count > 2
|
||||
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
|
||||
: offset;
|
||||
for (let i = 0; i < count; i++) {
|
||||
shorts.push(this.getUint16(dataView, shortOffset + i * 2, littleEndian));
|
||||
}
|
||||
return shorts;
|
||||
|
||||
case 4: // LONG
|
||||
if (count === 1) {
|
||||
return this.getUint32(dataView, offset, littleEndian);
|
||||
}
|
||||
const longs = [];
|
||||
const longOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
|
||||
for (let i = 0; i < count; i++) {
|
||||
longs.push(this.getUint32(dataView, longOffset + i * 4, littleEndian));
|
||||
}
|
||||
return longs;
|
||||
|
||||
case 5: // RATIONAL
|
||||
const ratOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
|
||||
if (count === 1) {
|
||||
const num = this.getUint32(dataView, ratOffset, littleEndian);
|
||||
const den = this.getUint32(dataView, ratOffset + 4, littleEndian);
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
const rationals = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const num = this.getUint32(dataView, ratOffset + i * 8, littleEndian);
|
||||
const den = this.getUint32(dataView, ratOffset + i * 8 + 4, littleEndian);
|
||||
rationals.push(den === 0 ? 0 : num / den);
|
||||
}
|
||||
return rationals;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert GPS coordinate to decimal degrees
|
||||
*/
|
||||
private convertGPSCoordinate(coord: number[], ref: string): number {
|
||||
if (!coord || coord.length !== 3) return 0;
|
||||
|
||||
const degrees = coord[0];
|
||||
const minutes = coord[1];
|
||||
const seconds = coord[2];
|
||||
|
||||
let decimal = degrees + minutes / 60 + seconds / 3600;
|
||||
|
||||
if (ref === 'S' || ref === 'W') {
|
||||
decimal = -decimal;
|
||||
}
|
||||
|
||||
return decimal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format EXIF data for display
|
||||
*/
|
||||
private formatExifData(data: ExifData): ExifData {
|
||||
const formatted: ExifData = { ...data };
|
||||
|
||||
// Format dates
|
||||
if (formatted.dateTime) {
|
||||
formatted.dateTime = this.parseExifDate(formatted.dateTime as any);
|
||||
}
|
||||
if (formatted.dateTimeOriginal) {
|
||||
formatted.dateTimeOriginal = this.parseExifDate(formatted.dateTimeOriginal as any);
|
||||
}
|
||||
if (formatted.dateTimeDigitized) {
|
||||
formatted.dateTimeDigitized = this.parseExifDate(formatted.dateTimeDigitized as any);
|
||||
}
|
||||
|
||||
// Format exposure time
|
||||
if (formatted.exposureTime) {
|
||||
const time = formatted.exposureTime as any;
|
||||
if (typeof time === 'number') {
|
||||
if (time < 1) {
|
||||
formatted.exposureTime = `1/${Math.round(1 / time)}`;
|
||||
} else {
|
||||
formatted.exposureTime = `${time}s`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format exposure program
|
||||
if (formatted.exposureProgram) {
|
||||
const programs = [
|
||||
'Not defined',
|
||||
'Manual',
|
||||
'Normal program',
|
||||
'Aperture priority',
|
||||
'Shutter priority',
|
||||
'Creative program',
|
||||
'Action program',
|
||||
'Portrait mode',
|
||||
'Landscape mode'
|
||||
];
|
||||
const index = formatted.exposureProgram as any;
|
||||
formatted.exposureProgram = programs[index] || 'Unknown';
|
||||
}
|
||||
|
||||
// Format metering mode
|
||||
if (formatted.meteringMode) {
|
||||
const modes = [
|
||||
'Unknown',
|
||||
'Average',
|
||||
'Center-weighted average',
|
||||
'Spot',
|
||||
'Multi-spot',
|
||||
'Pattern',
|
||||
'Partial'
|
||||
];
|
||||
const index = formatted.meteringMode as any;
|
||||
formatted.meteringMode = modes[index] || 'Unknown';
|
||||
}
|
||||
|
||||
// Format flash
|
||||
if (formatted.flash !== undefined) {
|
||||
const flash = formatted.flash as any;
|
||||
formatted.flash = (flash & 1) ? 'Flash fired' : 'Flash did not fire';
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EXIF date string
|
||||
*/
|
||||
private parseExifDate(dateStr: string): Date {
|
||||
// EXIF date format: "YYYY:MM:DD HH:MM:SS"
|
||||
const parts = dateStr.split(' ');
|
||||
if (parts.length !== 2) return new Date(dateStr);
|
||||
|
||||
const dateParts = parts[0].split(':');
|
||||
const timeParts = parts[1].split(':');
|
||||
|
||||
if (dateParts.length !== 3 || timeParts.length !== 3) {
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
return new Date(
|
||||
parseInt(dateParts[0]),
|
||||
parseInt(dateParts[1]) - 1,
|
||||
parseInt(dateParts[2]),
|
||||
parseInt(timeParts[0]),
|
||||
parseInt(timeParts[1]),
|
||||
parseInt(timeParts[2])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uint16 with endianness and bounds checking
|
||||
*/
|
||||
private getUint16(dataView: DataView, offset: number, littleEndian: boolean): number {
|
||||
if (offset + 2 > dataView.byteLength) {
|
||||
console.error('Uint16 read exceeds buffer bounds');
|
||||
return 0;
|
||||
}
|
||||
return dataView.getUint16(offset, littleEndian);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uint32 with endianness and bounds checking
|
||||
*/
|
||||
private getUint32(dataView: DataView, offset: number, littleEndian: boolean): number {
|
||||
if (offset + 4 > dataView.byteLength) {
|
||||
console.error('Uint32 read exceeds buffer bounds');
|
||||
return 0;
|
||||
}
|
||||
return dataView.getUint32(offset, littleEndian);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create EXIF display panel
|
||||
*/
|
||||
createExifPanel(exifData: ExifData): HTMLElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'exif-panel';
|
||||
panel.style.cssText = `
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Camera',
|
||||
fields: ['make', 'model', 'lensModel']
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
fields: ['exposureTime', 'fNumber', 'iso', 'focalLength', 'exposureProgram', 'meteringMode', 'flash']
|
||||
},
|
||||
{
|
||||
title: 'Image',
|
||||
fields: ['imageWidth', 'imageHeight', 'orientation', 'colorSpace', 'whiteBalance']
|
||||
},
|
||||
{
|
||||
title: 'Date/Time',
|
||||
fields: ['dateTimeOriginal', 'dateTime']
|
||||
},
|
||||
{
|
||||
title: 'Location',
|
||||
fields: ['gpsLatitude', 'gpsLongitude', 'gpsAltitude']
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
fields: ['software', 'artist', 'copyright', 'imageDescription']
|
||||
}
|
||||
];
|
||||
|
||||
sections.forEach(section => {
|
||||
const hasData = section.fields.some(field => (exifData as any)[field]);
|
||||
if (!hasData) return;
|
||||
|
||||
const sectionDiv = document.createElement('div');
|
||||
sectionDiv.style.marginBottom = '15px';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
// Use textContent for safe title insertion
|
||||
title.textContent = section.title;
|
||||
title.style.cssText = 'margin: 0 0 8px 0; color: #4CAF50;';
|
||||
title.setAttribute('aria-label', `Section: ${section.title}`);
|
||||
sectionDiv.appendChild(title);
|
||||
|
||||
section.fields.forEach(field => {
|
||||
const value = (exifData as any)[field];
|
||||
if (!value) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display: flex; justify-content: space-between; margin: 4px 0;';
|
||||
|
||||
const label = document.createElement('span');
|
||||
// Use textContent for safe text insertion
|
||||
label.textContent = this.formatFieldName(field) + ':';
|
||||
label.style.color = '#aaa';
|
||||
|
||||
const val = document.createElement('span');
|
||||
// Use textContent for safe value insertion
|
||||
val.textContent = this.formatFieldValue(field, value);
|
||||
val.style.textAlign = 'right';
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(val);
|
||||
sectionDiv.appendChild(row);
|
||||
});
|
||||
|
||||
panel.appendChild(sectionDiv);
|
||||
});
|
||||
|
||||
// Add GPS map link if coordinates available
|
||||
if (exifData.gpsLatitude && exifData.gpsLongitude) {
|
||||
const mapLink = document.createElement('a');
|
||||
mapLink.href = `https://www.google.com/maps?q=${exifData.gpsLatitude},${exifData.gpsLongitude}`;
|
||||
mapLink.target = '_blank';
|
||||
mapLink.textContent = 'View on Map';
|
||||
mapLink.style.cssText = `
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
panel.appendChild(mapLink);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field name for display
|
||||
*/
|
||||
private formatFieldName(field: string): string {
|
||||
const names: Record<string, string> = {
|
||||
make: 'Camera Make',
|
||||
model: 'Camera Model',
|
||||
lensModel: 'Lens',
|
||||
exposureTime: 'Shutter Speed',
|
||||
fNumber: 'Aperture',
|
||||
iso: 'ISO',
|
||||
focalLength: 'Focal Length',
|
||||
exposureProgram: 'Mode',
|
||||
meteringMode: 'Metering',
|
||||
flash: 'Flash',
|
||||
imageWidth: 'Width',
|
||||
imageHeight: 'Height',
|
||||
orientation: 'Orientation',
|
||||
colorSpace: 'Color Space',
|
||||
whiteBalance: 'White Balance',
|
||||
dateTimeOriginal: 'Date Taken',
|
||||
dateTime: 'Date Modified',
|
||||
gpsLatitude: 'Latitude',
|
||||
gpsLongitude: 'Longitude',
|
||||
gpsAltitude: 'Altitude',
|
||||
software: 'Software',
|
||||
artist: 'Artist',
|
||||
copyright: 'Copyright',
|
||||
imageDescription: 'Description'
|
||||
};
|
||||
return names[field] || field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field value for display
|
||||
*/
|
||||
private formatFieldValue(field: string, value: any): string {
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case 'fNumber':
|
||||
return `f/${value}`;
|
||||
case 'focalLength':
|
||||
return `${value}mm`;
|
||||
case 'gpsLatitude':
|
||||
case 'gpsLongitude':
|
||||
return value.toFixed(6) + '°';
|
||||
case 'gpsAltitude':
|
||||
return `${value.toFixed(1)}m`;
|
||||
case 'imageWidth':
|
||||
case 'imageHeight':
|
||||
return `${value}px`;
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cache with LRU eviction
|
||||
*/
|
||||
private addToCache(key: string, data: ExifData): void {
|
||||
// Remove from order if exists
|
||||
const existingIndex = this.cacheOrder.indexOf(key);
|
||||
if (existingIndex !== -1) {
|
||||
this.cacheOrder.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
// Add to end
|
||||
this.cacheOrder.push(key);
|
||||
this.exifCache.set(key, data);
|
||||
|
||||
// Evict oldest if over limit
|
||||
while (this.cacheOrder.length > this.MAX_CACHE_SIZE) {
|
||||
const oldestKey = this.cacheOrder.shift();
|
||||
if (oldestKey) {
|
||||
this.exifCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache order for LRU
|
||||
*/
|
||||
private updateCacheOrder(key: string): void {
|
||||
const index = this.cacheOrder.indexOf(key);
|
||||
if (index !== -1) {
|
||||
this.cacheOrder.splice(index, 1);
|
||||
this.cacheOrder.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear EXIF cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.exifCache.clear();
|
||||
this.cacheOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageExifService.getInstance();
|
||||
@@ -1,681 +0,0 @@
|
||||
/**
|
||||
* Image Sharing and Export Module for Trilium Notes
|
||||
* Provides functionality for sharing, downloading, and exporting images
|
||||
*/
|
||||
|
||||
import server from './server.js';
|
||||
import utils from './utils.js';
|
||||
import toastService from './toast.js';
|
||||
import type FNote from '../entities/fnote.js';
|
||||
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
|
||||
|
||||
/**
|
||||
* Export format options
|
||||
*/
|
||||
export type ExportFormat = 'original' | 'jpeg' | 'png' | 'webp';
|
||||
|
||||
/**
|
||||
* Export size presets
|
||||
*/
|
||||
export type SizePreset = 'original' | 'thumbnail' | 'small' | 'medium' | 'large' | 'custom';
|
||||
|
||||
/**
|
||||
* Export configuration
|
||||
*/
|
||||
export interface ExportConfig {
|
||||
format: ExportFormat;
|
||||
quality: number; // 0-100 for JPEG/WebP
|
||||
size: SizePreset;
|
||||
customWidth?: number;
|
||||
customHeight?: number;
|
||||
maintainAspectRatio: boolean;
|
||||
addWatermark: boolean;
|
||||
watermarkText?: string;
|
||||
watermarkPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
|
||||
watermarkOpacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share options
|
||||
*/
|
||||
export interface ShareOptions {
|
||||
method: 'link' | 'email' | 'social';
|
||||
expiresIn?: number; // Hours
|
||||
password?: string;
|
||||
allowDownload: boolean;
|
||||
trackViews: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share link data
|
||||
*/
|
||||
export interface ShareLink {
|
||||
url: string;
|
||||
shortUrl?: string;
|
||||
expiresAt?: Date;
|
||||
password?: string;
|
||||
views: number;
|
||||
maxViews?: number;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size presets in pixels
|
||||
*/
|
||||
const SIZE_PRESETS = {
|
||||
thumbnail: { width: 150, height: 150 },
|
||||
small: { width: 400, height: 400 },
|
||||
medium: { width: 800, height: 800 },
|
||||
large: { width: 1600, height: 1600 }
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageSharingService handles image sharing, downloading, and exporting
|
||||
*/
|
||||
class ImageSharingService {
|
||||
private static instance: ImageSharingService;
|
||||
private activeShares: Map<string, ShareLink> = new Map();
|
||||
private downloadCanvas?: HTMLCanvasElement;
|
||||
private downloadContext?: CanvasRenderingContext2D;
|
||||
|
||||
// Canvas size limits for security and memory management
|
||||
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
|
||||
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
|
||||
|
||||
private defaultExportConfig: ExportConfig = {
|
||||
format: 'original',
|
||||
quality: 90,
|
||||
size: 'original',
|
||||
maintainAspectRatio: true,
|
||||
addWatermark: false,
|
||||
watermarkPosition: 'bottom-right',
|
||||
watermarkOpacity: 0.5
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
// Initialize download canvas
|
||||
this.downloadCanvas = document.createElement('canvas');
|
||||
this.downloadContext = this.downloadCanvas.getContext('2d') || undefined;
|
||||
}
|
||||
|
||||
static getInstance(): ImageSharingService {
|
||||
if (!ImageSharingService.instance) {
|
||||
ImageSharingService.instance = new ImageSharingService();
|
||||
}
|
||||
return ImageSharingService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download image with options
|
||||
*/
|
||||
async downloadImage(
|
||||
src: string,
|
||||
filename: string,
|
||||
config?: Partial<ExportConfig>
|
||||
): Promise<void> {
|
||||
await withErrorBoundary(async () => {
|
||||
// Validate inputs
|
||||
ImageValidator.validateUrl(src);
|
||||
const sanitizedFilename = ImageValidator.sanitizeFilename(filename);
|
||||
const finalConfig = { ...this.defaultExportConfig, ...config };
|
||||
|
||||
// Load image
|
||||
const img = await this.loadImage(src);
|
||||
|
||||
// Process image based on config
|
||||
const processedBlob = await this.processImage(img, finalConfig);
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(processedBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Determine filename with extension
|
||||
const extension = finalConfig.format === 'original'
|
||||
? this.getOriginalExtension(sanitizedFilename)
|
||||
: finalConfig.format;
|
||||
const finalFilename = this.ensureExtension(sanitizedFilename, extension);
|
||||
|
||||
link.download = finalFilename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Cleanup
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toastService.showMessage(`Downloaded ${finalFilename}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image according to export configuration
|
||||
*/
|
||||
private async processImage(img: HTMLImageElement, config: ExportConfig): Promise<Blob> {
|
||||
if (!this.downloadCanvas || !this.downloadContext) {
|
||||
throw new Error('Canvas not initialized');
|
||||
}
|
||||
|
||||
// Calculate dimensions
|
||||
const { width, height } = this.calculateDimensions(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
config
|
||||
);
|
||||
|
||||
// Validate canvas dimensions
|
||||
ImageValidator.validateDimensions(width, height);
|
||||
|
||||
// Check memory availability
|
||||
const estimatedMemory = MemoryMonitor.estimateImageMemory(width, height);
|
||||
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
|
||||
throw new ImageError(
|
||||
ImageErrorType.MEMORY_ERROR,
|
||||
'Insufficient memory to process image',
|
||||
{ width, height, estimatedMemory }
|
||||
);
|
||||
}
|
||||
|
||||
// Set canvas size
|
||||
this.downloadCanvas.width = width;
|
||||
this.downloadCanvas.height = height;
|
||||
|
||||
// Clear canvas
|
||||
this.downloadContext.fillStyle = 'white';
|
||||
this.downloadContext.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw image
|
||||
this.downloadContext.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Add watermark if enabled
|
||||
if (config.addWatermark && config.watermarkText) {
|
||||
this.addWatermark(this.downloadContext, width, height, config);
|
||||
}
|
||||
|
||||
// Convert to blob
|
||||
return new Promise((resolve, reject) => {
|
||||
const mimeType = this.getMimeType(config.format);
|
||||
const quality = config.quality / 100;
|
||||
|
||||
this.downloadCanvas!.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
},
|
||||
mimeType,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dimensions based on size preset
|
||||
*/
|
||||
private calculateDimensions(
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
config: ExportConfig
|
||||
): { width: number; height: number } {
|
||||
if (config.size === 'original') {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
if (config.size === 'custom' && config.customWidth && config.customHeight) {
|
||||
if (config.maintainAspectRatio) {
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
const targetRatio = config.customWidth / config.customHeight;
|
||||
|
||||
if (aspectRatio > targetRatio) {
|
||||
return {
|
||||
width: config.customWidth,
|
||||
height: Math.round(config.customWidth / aspectRatio)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
width: Math.round(config.customHeight * aspectRatio),
|
||||
height: config.customHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
return { width: config.customWidth, height: config.customHeight };
|
||||
}
|
||||
|
||||
// Use preset
|
||||
const preset = SIZE_PRESETS[config.size as keyof typeof SIZE_PRESETS];
|
||||
if (!preset) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
if (config.maintainAspectRatio) {
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
const maxWidth = preset.width;
|
||||
const maxHeight = preset.height;
|
||||
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
height = Math.round(width / aspectRatio);
|
||||
}
|
||||
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = Math.round(height * aspectRatio);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add watermark to canvas
|
||||
*/
|
||||
private addWatermark(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
config: ExportConfig
|
||||
): void {
|
||||
if (!config.watermarkText) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Set watermark style
|
||||
ctx.globalAlpha = config.watermarkOpacity || 0.5;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.font = `${Math.min(width, height) * 0.05}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Calculate position
|
||||
let x = width / 2;
|
||||
let y = height / 2;
|
||||
|
||||
switch (config.watermarkPosition) {
|
||||
case 'top-left':
|
||||
x = width * 0.1;
|
||||
y = height * 0.1;
|
||||
ctx.textAlign = 'left';
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width * 0.9;
|
||||
y = height * 0.1;
|
||||
ctx.textAlign = 'right';
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = width * 0.1;
|
||||
y = height * 0.9;
|
||||
ctx.textAlign = 'left';
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width * 0.9;
|
||||
y = height * 0.9;
|
||||
ctx.textAlign = 'right';
|
||||
break;
|
||||
}
|
||||
|
||||
// Draw watermark with outline
|
||||
ctx.strokeText(config.watermarkText, x, y);
|
||||
ctx.fillText(config.watermarkText, x, y);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shareable link for image
|
||||
*/
|
||||
async generateShareLink(
|
||||
noteId: string,
|
||||
options?: Partial<ShareOptions>
|
||||
): Promise<ShareLink> {
|
||||
try {
|
||||
const finalOptions = {
|
||||
method: 'link' as const,
|
||||
allowDownload: true,
|
||||
trackViews: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Create share token on server
|
||||
const response = await server.post(`notes/${noteId}/share`, {
|
||||
type: 'image',
|
||||
expiresIn: finalOptions.expiresIn,
|
||||
password: finalOptions.password,
|
||||
allowDownload: finalOptions.allowDownload,
|
||||
trackViews: finalOptions.trackViews
|
||||
});
|
||||
|
||||
const shareLink: ShareLink = {
|
||||
url: `${window.location.origin}/share/${response.token}`,
|
||||
shortUrl: response.shortUrl,
|
||||
expiresAt: response.expiresAt ? new Date(response.expiresAt) : undefined,
|
||||
password: finalOptions.password,
|
||||
views: 0,
|
||||
maxViews: response.maxViews,
|
||||
created: new Date()
|
||||
};
|
||||
|
||||
// Store in active shares
|
||||
this.activeShares.set(response.token, shareLink);
|
||||
|
||||
return shareLink;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate share link:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy image or link to clipboard
|
||||
*/
|
||||
async copyToClipboard(
|
||||
src: string,
|
||||
type: 'image' | 'link' = 'link'
|
||||
): Promise<void> {
|
||||
await withErrorBoundary(async () => {
|
||||
// Validate URL
|
||||
ImageValidator.validateUrl(src);
|
||||
if (type === 'link') {
|
||||
// Copy URL to clipboard
|
||||
await navigator.clipboard.writeText(src);
|
||||
toastService.showMessage('Link copied to clipboard');
|
||||
} else {
|
||||
// Copy image data to clipboard
|
||||
const img = await this.loadImage(src);
|
||||
|
||||
if (!this.downloadCanvas || !this.downloadContext) {
|
||||
throw new Error('Canvas not initialized');
|
||||
}
|
||||
|
||||
// Validate dimensions before setting
|
||||
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
|
||||
|
||||
this.downloadCanvas.width = img.naturalWidth;
|
||||
this.downloadCanvas.height = img.naturalHeight;
|
||||
this.downloadContext.drawImage(img, 0, 0);
|
||||
|
||||
this.downloadCanvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
try {
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
await navigator.clipboard.write([item]);
|
||||
toastService.showMessage('Image copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy image to clipboard:', error);
|
||||
// Fallback to copying link
|
||||
await navigator.clipboard.writeText(src);
|
||||
toastService.showMessage('Image link copied to clipboard');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Share via native share API (mobile)
|
||||
*/
|
||||
async shareNative(
|
||||
src: string,
|
||||
title: string,
|
||||
text?: string
|
||||
): Promise<void> {
|
||||
if (!navigator.share) {
|
||||
throw new Error('Native share not supported');
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to share with file
|
||||
const img = await this.loadImage(src);
|
||||
const blob = await this.processImage(img, this.defaultExportConfig);
|
||||
const file = new File([blob], `${title}.${this.defaultExportConfig.format}`, {
|
||||
type: this.getMimeType(this.defaultExportConfig.format)
|
||||
});
|
||||
|
||||
await navigator.share({
|
||||
title,
|
||||
text: text || `Check out this image: ${title}`,
|
||||
files: [file]
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback to sharing URL
|
||||
try {
|
||||
await navigator.share({
|
||||
title,
|
||||
text: text || `Check out this image: ${title}`,
|
||||
url: src
|
||||
});
|
||||
} catch (shareError) {
|
||||
console.error('Failed to share:', shareError);
|
||||
throw shareError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export multiple images as ZIP
|
||||
*/
|
||||
async exportBatch(
|
||||
images: Array<{ src: string; filename: string }>,
|
||||
config?: Partial<ExportConfig>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Dynamic import of JSZip
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
const finalConfig = { ...this.defaultExportConfig, ...config };
|
||||
|
||||
// Process each image
|
||||
for (const { src, filename } of images) {
|
||||
try {
|
||||
const img = await this.loadImage(src);
|
||||
const blob = await this.processImage(img, finalConfig);
|
||||
const extension = finalConfig.format === 'original'
|
||||
? this.getOriginalExtension(filename)
|
||||
: finalConfig.format;
|
||||
const finalFilename = this.ensureExtension(filename, extension);
|
||||
|
||||
zip.file(finalFilename, blob);
|
||||
} catch (error) {
|
||||
console.error(`Failed to process image ${filename}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and download ZIP
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `images_${Date.now()}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toastService.showMessage(`Exported ${images.length} images`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export images:', error);
|
||||
toastService.showError('Failed to export images');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open share dialog
|
||||
*/
|
||||
openShareDialog(
|
||||
src: string,
|
||||
title: string,
|
||||
noteId?: string
|
||||
): void {
|
||||
// Create modal dialog
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'share-dialog-overlay';
|
||||
dialog.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'share-dialog';
|
||||
content.style.cssText = `
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
`;
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 style="margin: 0 0 15px 0;">Share Image</h3>
|
||||
<div class="share-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<button class="share-copy-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
|
||||
<i class="bx bx-link"></i> Copy Link
|
||||
</button>
|
||||
<button class="share-copy-image" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
|
||||
<i class="bx bx-copy"></i> Copy Image
|
||||
</button>
|
||||
<button class="share-download" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
|
||||
<i class="bx bx-download"></i> Download
|
||||
</button>
|
||||
${navigator.share ? `
|
||||
<button class="share-native" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
|
||||
<i class="bx bx-share"></i> Share...
|
||||
</button>
|
||||
` : ''}
|
||||
${noteId ? `
|
||||
<button class="share-generate-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
|
||||
<i class="bx bx-link-external"></i> Generate Share Link
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<button class="close-dialog" style="margin-top: 15px; padding: 8px 16px; background: #f0f0f0; border: none; border-radius: 4px; cursor: pointer;">
|
||||
Close
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event handlers
|
||||
content.querySelector('.share-copy-link')?.addEventListener('click', () => {
|
||||
this.copyToClipboard(src, 'link');
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
content.querySelector('.share-copy-image')?.addEventListener('click', () => {
|
||||
this.copyToClipboard(src, 'image');
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
content.querySelector('.share-download')?.addEventListener('click', () => {
|
||||
this.downloadImage(src, title);
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
content.querySelector('.share-native')?.addEventListener('click', () => {
|
||||
this.shareNative(src, title);
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
content.querySelector('.share-generate-link')?.addEventListener('click', async () => {
|
||||
if (noteId) {
|
||||
const link = await this.generateShareLink(noteId);
|
||||
await this.copyToClipboard(link.url, 'link');
|
||||
dialog.remove();
|
||||
}
|
||||
});
|
||||
|
||||
content.querySelector('.close-dialog')?.addEventListener('click', () => {
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
dialog.appendChild(content);
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from URL
|
||||
*/
|
||||
private loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for format
|
||||
*/
|
||||
private getMimeType(format: ExportFormat): string {
|
||||
switch (format) {
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original extension from filename
|
||||
*/
|
||||
private getOriginalExtension(filename: string): string {
|
||||
const parts = filename.split('.');
|
||||
if (parts.length > 1) {
|
||||
return parts[parts.length - 1].toLowerCase();
|
||||
}
|
||||
return 'png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure filename has correct extension
|
||||
*/
|
||||
private ensureExtension(filename: string, extension: string): string {
|
||||
const parts = filename.split('.');
|
||||
if (parts.length > 1) {
|
||||
parts[parts.length - 1] = extension;
|
||||
return parts.join('.');
|
||||
}
|
||||
return `${filename}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.activeShares.clear();
|
||||
|
||||
// Clean up canvas memory
|
||||
if (this.downloadCanvas && this.downloadContext) {
|
||||
this.downloadContext.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height);
|
||||
this.downloadCanvas.width = 0;
|
||||
this.downloadCanvas.height = 0;
|
||||
}
|
||||
|
||||
this.downloadCanvas = undefined;
|
||||
this.downloadContext = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageSharingService.getInstance();
|
||||
@@ -4,6 +4,7 @@ import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
type BooleanLike = boolean | "true" | "false";
|
||||
|
||||
@@ -66,7 +67,7 @@ function makeToast(id: string, message: string): ToastOptions {
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "importNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,8 +88,8 @@ ws.subscribeToMessages(async (message) => {
|
||||
}
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importAttachments") {
|
||||
ws.subscribeToMessages(async (message: WebSocketMessage) => {
|
||||
if (!("taskType" in message) || message.taskType !== "importAttachments") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { byBookType, byNoteType } from "./help_button.js";
|
||||
import { byBookType, byNoteType } from "./in_app_help.js";
|
||||
import fs from "fs";
|
||||
import type { HiddenSubtreeItem } from "@triliumnext/commons";
|
||||
import path from "path";
|
||||
@@ -25,7 +25,7 @@ describe("Help button", () => {
|
||||
...Object.values(byBookType)
|
||||
].filter((noteId) => noteId) as string[];
|
||||
|
||||
const metaPath = path.resolve(path.join(__dirname, "../../../../server/src/assets/doc_notes/en/User Guide/!!!meta.json"));
|
||||
const metaPath = path.resolve(path.join(__dirname, "../../../server/src/assets/doc_notes/en/User Guide/!!!meta.json"));
|
||||
const meta: HiddenSubtreeItem[] = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
||||
const allNoteIds = new Set(getNoteIds(meta));
|
||||
|
||||
43
apps/client/src/services/in_app_help.ts
Normal file
43
apps/client/src/services/in_app_help.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import FNote from "../entities/fnote";
|
||||
import { ViewTypeOptions } from "../widgets/collections/interface";
|
||||
|
||||
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
canvas: null,
|
||||
code: null,
|
||||
contentWidget: null,
|
||||
doc: null,
|
||||
file: null,
|
||||
image: null,
|
||||
launcher: null,
|
||||
mermaid: null,
|
||||
mindMap: null,
|
||||
noteMap: null,
|
||||
relationMap: null,
|
||||
render: null,
|
||||
search: null,
|
||||
text: null,
|
||||
webView: null,
|
||||
aiChat: null
|
||||
};
|
||||
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
list: "mULW0Q3VojwY",
|
||||
grid: "8QqnMzx393bx",
|
||||
calendar: "xWbu3jpNWapp",
|
||||
table: "2FvYrpmOXm29",
|
||||
geoMap: "81SGnPGMk7Xc",
|
||||
board: "CtBQqbwXDx1w"
|
||||
};
|
||||
|
||||
export function getHelpUrlForNote(note: FNote | null | undefined) {
|
||||
if (note && note.type !== "book" && byNoteType[note.type]) {
|
||||
return byNoteType[note.type];
|
||||
} else if (note?.hasLabel("calendarRoot")) {
|
||||
return "l0tKav7yLHGF";
|
||||
} else if (note?.hasLabel("textSnippet")) {
|
||||
return "pwc194wlRzcH";
|
||||
} else if (note && note.type === "book") {
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ async function getAction(actionName: string, silent = false) {
|
||||
return action;
|
||||
}
|
||||
|
||||
export function getActionSync(actionName: string) {
|
||||
return keyboardActionRepo[actionName];
|
||||
}
|
||||
|
||||
function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
//@ts-ignore
|
||||
//TODO: each() does not support async callbacks.
|
||||
|
||||
@@ -35,8 +35,7 @@ async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
// TODO: Remove `string` once all the view modes have been mapped.
|
||||
type ViewMode = "default" | "source" | "attachments" | "contextual-help" | string;
|
||||
export type ViewMode = "default" | "source" | "attachments" | "contextual-help";
|
||||
|
||||
export interface ViewScope {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type { AttachmentRow, EtapiTokenRow, OptionNames } from "@triliumnext/commons";
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
|
||||
@@ -53,6 +53,7 @@ type EntityRowMappings = {
|
||||
options: OptionRow;
|
||||
revisions: RevisionRow;
|
||||
note_reordering: NoteReorderingRow;
|
||||
etapi_tokens: EtapiTokenRow;
|
||||
};
|
||||
|
||||
export type EntityRowNames = keyof EntityRowMappings;
|
||||
@@ -66,8 +67,9 @@ export default class LoadResults {
|
||||
private revisionRows: RevisionRow[];
|
||||
private noteReorderings: string[];
|
||||
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
|
||||
private optionNames: string[];
|
||||
private optionNames: OptionNames[];
|
||||
private attachmentRows: AttachmentRow[];
|
||||
public hasEtapiTokenChanges: boolean = false;
|
||||
|
||||
constructor(entityChanges: EntityChange[]) {
|
||||
const entities: Record<string, Record<string, any>> = {};
|
||||
@@ -178,11 +180,11 @@ export default class LoadResults {
|
||||
return this.contentNoteIdToComponentId.find((l) => l.noteId === noteId && l.componentId !== componentId);
|
||||
}
|
||||
|
||||
addOption(name: string) {
|
||||
addOption(name: OptionNames) {
|
||||
this.optionNames.push(name);
|
||||
}
|
||||
|
||||
isOptionReloaded(name: string) {
|
||||
isOptionReloaded(name: OptionNames) {
|
||||
return this.optionNames.includes(name);
|
||||
}
|
||||
|
||||
@@ -215,7 +217,8 @@ export default class LoadResults {
|
||||
this.revisionRows.length === 0 &&
|
||||
this.contentNoteIdToComponentId.length === 0 &&
|
||||
this.optionNames.length === 0 &&
|
||||
this.attachmentRows.length === 0
|
||||
this.attachmentRows.length === 0 &&
|
||||
!this.hasEtapiTokenChanges
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import type PhotoSwipeOptions from 'photoswipe';
|
||||
import type { DataSource, SlideData } from 'photoswipe';
|
||||
import 'photoswipe/style.css';
|
||||
import '../styles/photoswipe-mobile-a11y.css';
|
||||
import mobileA11yService, { type MobileA11yConfig } from './photoswipe_mobile_a11y.js';
|
||||
|
||||
// Define Content type locally since it's not exported by PhotoSwipe
|
||||
interface Content {
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Define AugmentedEvent type locally
|
||||
interface AugmentedEvent<T extends string> {
|
||||
content: Content;
|
||||
slide?: any;
|
||||
preventDefault?: () => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media item interface for PhotoSwipe gallery
|
||||
*/
|
||||
export interface MediaItem {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
noteId?: string;
|
||||
element?: HTMLElement;
|
||||
msrc?: string; // Thumbnail source
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the media viewer
|
||||
*/
|
||||
export interface MediaViewerConfig {
|
||||
bgOpacity?: number;
|
||||
showHideOpacity?: boolean;
|
||||
showAnimationDuration?: number;
|
||||
hideAnimationDuration?: number;
|
||||
allowPanToNext?: boolean;
|
||||
spacing?: number;
|
||||
maxSpreadZoom?: number;
|
||||
getThumbBoundsFn?: (index: number) => { x: number; y: number; w: number } | undefined;
|
||||
pinchToClose?: boolean;
|
||||
closeOnScroll?: boolean;
|
||||
closeOnVerticalDrag?: boolean;
|
||||
mouseMovePan?: boolean;
|
||||
arrowKeys?: boolean;
|
||||
returnFocus?: boolean;
|
||||
escKey?: boolean;
|
||||
errorMsg?: string;
|
||||
preloadFirstSlide?: boolean;
|
||||
preload?: [number, number];
|
||||
loop?: boolean;
|
||||
wheelToZoom?: boolean;
|
||||
mobileA11y?: MobileA11yConfig; // Mobile and accessibility configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Event callbacks for media viewer
|
||||
*/
|
||||
export interface MediaViewerCallbacks {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onChange?: (index: number) => void;
|
||||
onImageLoad?: (index: number, item: MediaItem) => void;
|
||||
onImageError?: (index: number, item: MediaItem, error?: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PhotoSwipe data item with original item reference
|
||||
*/
|
||||
interface PhotoSwipeDataItem extends SlideData {
|
||||
_originalItem?: MediaItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler for media viewer operations
|
||||
*/
|
||||
class MediaViewerError extends Error {
|
||||
constructor(message: string, public readonly cause?: unknown) {
|
||||
super(message);
|
||||
this.name = 'MediaViewerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaViewerService manages the PhotoSwipe lightbox for viewing images and media
|
||||
* in Trilium Notes. Implements singleton pattern for global access.
|
||||
*/
|
||||
class MediaViewerService {
|
||||
private static instance: MediaViewerService;
|
||||
private photoSwipe: PhotoSwipe | null = null;
|
||||
private defaultConfig: MediaViewerConfig;
|
||||
private currentItems: MediaItem[] = [];
|
||||
private callbacks: MediaViewerCallbacks = {};
|
||||
private cleanupHandlers: Array<() => void> = [];
|
||||
|
||||
private constructor() {
|
||||
// Default configuration optimized for Trilium
|
||||
this.defaultConfig = {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
showAnimationDuration: 250,
|
||||
hideAnimationDuration: 250,
|
||||
allowPanToNext: true,
|
||||
spacing: 0.12,
|
||||
maxSpreadZoom: 4,
|
||||
pinchToClose: true,
|
||||
closeOnScroll: false,
|
||||
closeOnVerticalDrag: true,
|
||||
mouseMovePan: true,
|
||||
arrowKeys: true,
|
||||
returnFocus: true,
|
||||
escKey: true,
|
||||
errorMsg: 'The image could not be loaded',
|
||||
preloadFirstSlide: true,
|
||||
preload: [1, 2],
|
||||
loop: true,
|
||||
wheelToZoom: true
|
||||
};
|
||||
|
||||
// Setup global cleanup on window unload
|
||||
window.addEventListener('beforeunload', () => this.destroy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance of MediaViewerService
|
||||
*/
|
||||
static getInstance(): MediaViewerService {
|
||||
if (!MediaViewerService.instance) {
|
||||
MediaViewerService.instance = new MediaViewerService();
|
||||
}
|
||||
return MediaViewerService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the media viewer with specified items
|
||||
*/
|
||||
open(items: MediaItem[], startIndex: number = 0, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!items || items.length === 0) {
|
||||
throw new MediaViewerError('No items provided to media viewer');
|
||||
}
|
||||
|
||||
if (startIndex < 0 || startIndex >= items.length) {
|
||||
console.warn(`Invalid start index ${startIndex}, using 0`);
|
||||
startIndex = 0;
|
||||
}
|
||||
|
||||
// Close any existing viewer
|
||||
this.close();
|
||||
|
||||
this.currentItems = items;
|
||||
this.callbacks = callbacks || {};
|
||||
|
||||
// Prepare data source for PhotoSwipe with error handling
|
||||
const dataSource: DataSource = items.map((item, index) => {
|
||||
try {
|
||||
return this.prepareItem(item);
|
||||
} catch (error) {
|
||||
console.error(`Failed to prepare item at index ${index}:`, error);
|
||||
// Return a minimal valid item as fallback
|
||||
return {
|
||||
src: item.src,
|
||||
width: 800,
|
||||
height: 600,
|
||||
alt: item.alt || 'Error loading image'
|
||||
} as PhotoSwipeDataItem;
|
||||
}
|
||||
});
|
||||
|
||||
// Merge configurations
|
||||
const finalConfig = {
|
||||
...this.defaultConfig,
|
||||
...config,
|
||||
dataSource,
|
||||
index: startIndex,
|
||||
errorMsg: config?.errorMsg || 'The image could not be loaded. Please try again.'
|
||||
};
|
||||
|
||||
// Create and initialize PhotoSwipe
|
||||
this.photoSwipe = new PhotoSwipe(finalConfig);
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Apply mobile and accessibility enhancements
|
||||
if (config?.mobileA11y || this.shouldAutoEnhance()) {
|
||||
mobileA11yService.enhancePhotoSwipe(this.photoSwipe, config?.mobileA11y);
|
||||
}
|
||||
|
||||
// Initialize the viewer
|
||||
this.photoSwipe.init();
|
||||
} catch (error) {
|
||||
console.error('Failed to open media viewer:', error);
|
||||
// Cleanup on error
|
||||
this.close();
|
||||
// Re-throw as MediaViewerError
|
||||
throw error instanceof MediaViewerError ? error : new MediaViewerError('Failed to open media viewer', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a single image in the viewer
|
||||
*/
|
||||
openSingle(item: MediaItem, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
|
||||
this.open([item], 0, config, callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the media viewer
|
||||
*/
|
||||
close(): void {
|
||||
if (this.photoSwipe) {
|
||||
this.photoSwipe.destroy();
|
||||
this.photoSwipe = null;
|
||||
this.cleanupEventHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next item
|
||||
*/
|
||||
next(): void {
|
||||
if (this.photoSwipe) {
|
||||
this.photoSwipe.next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous item
|
||||
*/
|
||||
prev(): void {
|
||||
if (this.photoSwipe) {
|
||||
this.photoSwipe.prev();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific slide by index
|
||||
*/
|
||||
goTo(index: number): void {
|
||||
if (this.photoSwipe && index >= 0 && index < this.currentItems.length) {
|
||||
this.photoSwipe.goTo(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current slide index
|
||||
*/
|
||||
getCurrentIndex(): number {
|
||||
return this.photoSwipe ? this.photoSwipe.currIndex : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if viewer is open
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.photoSwipe !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration dynamically
|
||||
*/
|
||||
updateConfig(config: Partial<MediaViewerConfig>): void {
|
||||
this.defaultConfig = {
|
||||
...this.defaultConfig,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare item for PhotoSwipe
|
||||
*/
|
||||
private prepareItem(item: MediaItem): PhotoSwipeDataItem {
|
||||
const prepared: PhotoSwipeDataItem = {
|
||||
src: item.src,
|
||||
alt: item.alt || '',
|
||||
title: item.title
|
||||
};
|
||||
|
||||
// If dimensions are provided, use them
|
||||
if (item.width && item.height) {
|
||||
prepared.width = item.width;
|
||||
prepared.height = item.height;
|
||||
} else {
|
||||
// Default dimensions - will be updated when image loads
|
||||
prepared.width = 0;
|
||||
prepared.height = 0;
|
||||
}
|
||||
|
||||
// Add thumbnail if provided
|
||||
if (item.msrc) {
|
||||
prepared.msrc = item.msrc;
|
||||
}
|
||||
|
||||
// Store original item reference
|
||||
prepared._originalItem = item;
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for PhotoSwipe
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.photoSwipe) return;
|
||||
|
||||
// Opening event
|
||||
const openHandler = () => {
|
||||
if (this.callbacks.onOpen) {
|
||||
this.callbacks.onOpen();
|
||||
}
|
||||
};
|
||||
this.photoSwipe.on('openingAnimationEnd', openHandler);
|
||||
this.cleanupHandlers.push(() => this.photoSwipe?.off('openingAnimationEnd', openHandler));
|
||||
|
||||
// Closing event
|
||||
const closeHandler = () => {
|
||||
if (this.callbacks.onClose) {
|
||||
this.callbacks.onClose();
|
||||
}
|
||||
};
|
||||
this.photoSwipe.on('close', closeHandler);
|
||||
this.cleanupHandlers.push(() => this.photoSwipe?.off('close', closeHandler));
|
||||
|
||||
// Change event
|
||||
const changeHandler = () => {
|
||||
if (this.callbacks.onChange && this.photoSwipe) {
|
||||
this.callbacks.onChange(this.photoSwipe.currIndex);
|
||||
}
|
||||
};
|
||||
this.photoSwipe.on('change', changeHandler);
|
||||
this.cleanupHandlers.push(() => this.photoSwipe?.off('change', changeHandler));
|
||||
|
||||
// Image load event - also update dimensions if needed
|
||||
const loadCompleteHandler = (e: any) => {
|
||||
try {
|
||||
const { content } = e;
|
||||
const extContent = content as Content & { type?: string; data?: HTMLImageElement; index?: number; _originalItem?: MediaItem };
|
||||
|
||||
if (extContent.type === 'image' && extContent.data) {
|
||||
// Update dimensions if they were not provided
|
||||
if (content.width === 0 || content.height === 0) {
|
||||
const img = extContent.data;
|
||||
content.width = img.naturalWidth;
|
||||
content.height = img.naturalHeight;
|
||||
if (typeof extContent.index === 'number') {
|
||||
this.photoSwipe?.refreshSlideContent(extContent.index);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.callbacks.onImageLoad && typeof extContent.index === 'number' && extContent._originalItem) {
|
||||
this.callbacks.onImageLoad(extContent.index, extContent._originalItem);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in loadComplete handler:', error);
|
||||
}
|
||||
};
|
||||
this.photoSwipe.on('loadComplete', loadCompleteHandler);
|
||||
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadComplete', loadCompleteHandler));
|
||||
|
||||
// Image error event
|
||||
const errorHandler = (e: any) => {
|
||||
try {
|
||||
const { content } = e;
|
||||
const extContent = content as Content & { index?: number; _originalItem?: MediaItem };
|
||||
|
||||
if (this.callbacks.onImageError && typeof extContent.index === 'number' && extContent._originalItem) {
|
||||
const error = new MediaViewerError(`Failed to load image at index ${extContent.index}`);
|
||||
this.callbacks.onImageError(extContent.index, extContent._originalItem, error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in errorHandler:', error);
|
||||
}
|
||||
};
|
||||
this.photoSwipe.on('loadError', errorHandler);
|
||||
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadError', errorHandler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup event handlers
|
||||
*/
|
||||
private cleanupEventHandlers(): void {
|
||||
this.cleanupHandlers.forEach(handler => handler());
|
||||
this.cleanupHandlers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the service and cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.close();
|
||||
this.currentItems = [];
|
||||
this.callbacks = {};
|
||||
|
||||
// Cleanup mobile and accessibility enhancements
|
||||
mobileA11yService.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dimensions from image element or URL with proper resource cleanup
|
||||
*/
|
||||
async getImageDimensions(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
// Clear the src to help with garbage collection
|
||||
if (!resolved) {
|
||||
img.src = '';
|
||||
}
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
resolved = true;
|
||||
const dimensions = {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
};
|
||||
cleanup();
|
||||
resolve(dimensions);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
const error = new MediaViewerError(`Failed to load image: ${src}`);
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Set a timeout for image loading
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
reject(new MediaViewerError(`Image loading timeout: ${src}`));
|
||||
}
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
img.src = src;
|
||||
|
||||
// Clear timeout on success or error
|
||||
// Store the original handlers with timeout cleanup
|
||||
const originalOnload = img.onload;
|
||||
const originalOnerror = img.onerror;
|
||||
|
||||
img.onload = function(ev: Event) {
|
||||
clearTimeout(timeoutId);
|
||||
if (originalOnload) {
|
||||
originalOnload.call(img, ev);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = function(ev: Event | string) {
|
||||
clearTimeout(timeoutId);
|
||||
if (originalOnerror) {
|
||||
originalOnerror.call(img, ev);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create items from image elements in a container with error isolation
|
||||
*/
|
||||
async createItemsFromContainer(container: HTMLElement, selector: string = 'img'): Promise<MediaItem[]> {
|
||||
const images = container.querySelectorAll<HTMLImageElement>(selector);
|
||||
const items: MediaItem[] = [];
|
||||
|
||||
// Process each image with isolated error handling
|
||||
const promises = Array.from(images).map(async (img) => {
|
||||
try {
|
||||
const item: MediaItem = {
|
||||
src: img.src,
|
||||
alt: img.alt || `Image ${items.length + 1}`,
|
||||
title: img.title || img.alt || `Image ${items.length + 1}`,
|
||||
element: img,
|
||||
width: img.naturalWidth || undefined,
|
||||
height: img.naturalHeight || undefined
|
||||
};
|
||||
|
||||
// Try to get dimensions if not available
|
||||
if (!item.width || !item.height) {
|
||||
try {
|
||||
const dimensions = await this.getImageDimensions(img.src);
|
||||
item.width = dimensions.width;
|
||||
item.height = dimensions.height;
|
||||
} catch (error) {
|
||||
// Log but don't fail - image will still be viewable
|
||||
console.warn(`Failed to get dimensions for image: ${img.src}`, error);
|
||||
// Set default dimensions as fallback
|
||||
item.width = 800;
|
||||
item.height = 600;
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
// Log error but continue processing other images
|
||||
console.error(`Failed to process image: ${img.src}`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all promises and filter out nulls
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value !== null) {
|
||||
items.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme-specific styles
|
||||
*/
|
||||
applyTheme(isDarkTheme: boolean): void {
|
||||
// This will be expanded to modify PhotoSwipe's appearance based on Trilium's theme
|
||||
const opacity = isDarkTheme ? 0.95 : 0.9;
|
||||
this.updateConfig({ bgOpacity: opacity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mobile/accessibility enhancements should be auto-enabled
|
||||
*/
|
||||
private shouldAutoEnhance(): boolean {
|
||||
// Auto-enable for touch devices
|
||||
const isTouchDevice = 'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0;
|
||||
|
||||
// Auto-enable if user has accessibility preferences
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const prefersHighContrast = window.matchMedia('(prefers-contrast: high)').matches;
|
||||
|
||||
return isTouchDevice || prefersReducedMotion || prefersHighContrast;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default MediaViewerService.getInstance();
|
||||
@@ -36,6 +36,8 @@ export interface Suggestion {
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
@@ -323,7 +325,33 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
// Add special class for search-notes action
|
||||
const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : "";
|
||||
|
||||
// Choose appropriate icon based on action
|
||||
let iconClass = suggestion.icon ?? "bx bx-note";
|
||||
if (suggestion.action === "search-notes") {
|
||||
iconClass = "bx bx-search";
|
||||
} else if (suggestion.action === "create-note") {
|
||||
iconClass = "bx bx-plus";
|
||||
} else if (suggestion.action === "external-link") {
|
||||
iconClass = "bx bx-link-external";
|
||||
}
|
||||
|
||||
// Simplified HTML structure without nested divs
|
||||
let html = `<div class="note-suggestion ${actionClass}">`;
|
||||
html += `<span class="icon ${iconClass}"></span>`;
|
||||
html += `<span class="text">`;
|
||||
html += `<span class="search-result-title">${suggestion.highlightedNotePathTitle}</span>`;
|
||||
|
||||
// Add attribute snippet inline if available
|
||||
if (suggestion.highlightedAttributeSnippet) {
|
||||
html += `<span class="search-result-attributes">${suggestion.highlightedAttributeSnippet}</span>`;
|
||||
}
|
||||
|
||||
html += `</span>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import BoardView from "../widgets/view_widgets/board_view/index.js";
|
||||
import CalendarView from "../widgets/view_widgets/calendar_view.js";
|
||||
import GeoView from "../widgets/view_widgets/geo_view/index.js";
|
||||
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
|
||||
import TableView from "../widgets/view_widgets/table_view/index.js";
|
||||
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
|
||||
import type ViewMode from "../widgets/view_widgets/view_mode.js";
|
||||
|
||||
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
|
||||
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
private viewType: ViewTypeOptions;
|
||||
private args: ArgsWithoutNoteId;
|
||||
public viewMode?: ViewMode<any>;
|
||||
|
||||
constructor(args: ArgsWithoutNoteId) {
|
||||
this.args = args;
|
||||
this.viewType = this.#getViewType(args.parentNote);
|
||||
}
|
||||
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
const viewType = parentNote.getLabelValue("viewType");
|
||||
|
||||
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
|
||||
// when not explicitly set, decide based on the note type
|
||||
return parentNote.type === "search" ? "list" : "grid";
|
||||
} else {
|
||||
return viewType as ViewTypeOptions;
|
||||
}
|
||||
}
|
||||
|
||||
get isFullHeight() {
|
||||
switch (this.viewType) {
|
||||
case "list":
|
||||
case "grid":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async renderList() {
|
||||
const args = this.args;
|
||||
const viewMode = this.#buildViewMode(args);
|
||||
this.viewMode = viewMode;
|
||||
await viewMode.beforeRender();
|
||||
return await viewMode.renderList();
|
||||
}
|
||||
|
||||
#buildViewMode(args: ViewModeArgs) {
|
||||
switch (this.viewType) {
|
||||
case "calendar":
|
||||
return new CalendarView(args);
|
||||
case "table":
|
||||
return new TableView(args);
|
||||
case "geoMap":
|
||||
return new GeoView(args);
|
||||
case "board":
|
||||
return new BoardView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
return new ListOrGridView(this.viewType, args);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "./i18n.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
|
||||
@@ -73,7 +73,7 @@ const BETA_BADGE = {
|
||||
title: t("note_types.beta-feature")
|
||||
};
|
||||
|
||||
const SEPARATOR = { title: "----" };
|
||||
const SEPARATOR: MenuSeparatorItem = { kind: "separator" };
|
||||
|
||||
const creationDateCache = new Map<string, Date>();
|
||||
let rootCreationDate: Date | undefined;
|
||||
@@ -81,8 +81,8 @@ let rootCreationDate: Date | undefined;
|
||||
async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
...getBlankNoteTypes(command),
|
||||
...await getBuiltInTemplates(t("note_types.collections"), command, true),
|
||||
...await getBuiltInTemplates(null, command, false),
|
||||
...await getBuiltInTemplates(t("note_types.collections"), command, true),
|
||||
...await getUserTemplates(command)
|
||||
];
|
||||
|
||||
@@ -121,7 +121,10 @@ async function getUserTemplates(command?: TreeCommandNames) {
|
||||
}
|
||||
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
SEPARATOR
|
||||
{
|
||||
title: t("note_type_chooser.templates"),
|
||||
kind: "header"
|
||||
}
|
||||
];
|
||||
|
||||
for (const templateNote of templateNotes) {
|
||||
@@ -158,8 +161,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
|
||||
if (title) {
|
||||
items.push({
|
||||
title: title,
|
||||
enabled: false,
|
||||
uiIcon: "bx bx-empty"
|
||||
kind: "header"
|
||||
});
|
||||
} else {
|
||||
items.push(SEPARATOR);
|
||||
|
||||
@@ -35,7 +35,7 @@ function download(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFileNote(noteId: string) {
|
||||
export function downloadFileNote(noteId: string) {
|
||||
const url = `${getFileUrl("notes", noteId)}?${Date.now()}`; // don't use cache
|
||||
|
||||
download(url);
|
||||
@@ -163,7 +163,7 @@ async function openExternally(type: string, entityId: string, mime: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
export const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
const openAttachmentExternally = async (attachmentId: string, mime: string) => await openExternally("attachments", attachmentId, mime);
|
||||
|
||||
function getHost() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import server from "./server.js";
|
||||
import { isShare } from "./utils.js";
|
||||
|
||||
type OptionValue = number | string;
|
||||
export type OptionValue = number | string;
|
||||
|
||||
class Options {
|
||||
initializedPromise: Promise<void>;
|
||||
@@ -19,7 +20,7 @@ class Options {
|
||||
this.arr = arr;
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
get(key: OptionNames) {
|
||||
return this.arr?.[key] as string;
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ class Options {
|
||||
}
|
||||
}
|
||||
|
||||
getInt(key: string) {
|
||||
getInt(key: OptionNames) {
|
||||
const value = this.arr?.[key];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
@@ -51,7 +52,7 @@ class Options {
|
||||
return null;
|
||||
}
|
||||
|
||||
getFloat(key: string) {
|
||||
getFloat(key: OptionNames) {
|
||||
const value = this.arr?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -59,15 +60,15 @@ class Options {
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
is(key: string) {
|
||||
is(key: OptionNames) {
|
||||
return this.arr[key] === "true";
|
||||
}
|
||||
|
||||
set(key: string, value: OptionValue) {
|
||||
set(key: OptionNames, value: OptionValue) {
|
||||
this.arr[key] = value;
|
||||
}
|
||||
|
||||
async save(key: string, value: OptionValue) {
|
||||
async save(key: OptionNames, value: OptionValue) {
|
||||
this.set(key, value);
|
||||
|
||||
const payload: Record<string, OptionValue> = {};
|
||||
@@ -76,7 +77,15 @@ class Options {
|
||||
await server.put(`options`, payload);
|
||||
}
|
||||
|
||||
async toggle(key: string) {
|
||||
/**
|
||||
* Saves multiple options at once, by supplying a record where the keys are the option names and the values represent the stringified value to set.
|
||||
* @param newValues the record of keys and values.
|
||||
*/
|
||||
async saveMany<T extends OptionNames>(newValues: Record<T, OptionValue>) {
|
||||
await server.put<void>("options", newValues);
|
||||
}
|
||||
|
||||
async toggle(key: OptionNames) {
|
||||
await this.save(key, (!this.is(key)).toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
/**
|
||||
* Tests for PhotoSwipe Mobile & Accessibility Enhancement Module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import type PhotoSwipe from 'photoswipe';
|
||||
import mobileA11yService from './photoswipe_mobile_a11y.js';
|
||||
|
||||
// Mock PhotoSwipe
|
||||
const mockPhotoSwipe = {
|
||||
template: document.createElement('div'),
|
||||
currSlide: {
|
||||
currZoomLevel: 1,
|
||||
zoomTo: jest.fn(),
|
||||
data: {
|
||||
src: 'test.jpg',
|
||||
alt: 'Test image',
|
||||
title: 'Test',
|
||||
width: 800,
|
||||
height: 600
|
||||
}
|
||||
},
|
||||
currIndex: 0,
|
||||
viewportSize: { x: 800, y: 600 },
|
||||
ui: { toggle: jest.fn() },
|
||||
next: jest.fn(),
|
||||
prev: jest.fn(),
|
||||
goTo: jest.fn(),
|
||||
close: jest.fn(),
|
||||
getNumItems: () => 5,
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
options: {
|
||||
showAnimationDuration: 250,
|
||||
hideAnimationDuration: 250
|
||||
}
|
||||
} as unknown as PhotoSwipe;
|
||||
|
||||
describe('PhotoSwipeMobileA11yService', () => {
|
||||
beforeEach(() => {
|
||||
// Reset DOM
|
||||
document.body.innerHTML = '';
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
mobileA11yService.cleanup();
|
||||
});
|
||||
|
||||
describe('Device Capabilities Detection', () => {
|
||||
it('should detect touch device capabilities', () => {
|
||||
// Add touch support to window
|
||||
Object.defineProperty(window, 'ontouchstart', {
|
||||
value: () => {},
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Service should detect touch support on initialization
|
||||
const service = mobileA11yService;
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect accessibility preferences', () => {
|
||||
// Mock matchMedia for reduced motion
|
||||
const mockMatchMedia = jest.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn()
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: mockMatchMedia,
|
||||
writable: true
|
||||
});
|
||||
|
||||
const service = mobileA11yService;
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARIA Live Region', () => {
|
||||
it('should create ARIA live region for announcements', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live]');
|
||||
expect(liveRegion).toBeTruthy();
|
||||
expect(liveRegion?.getAttribute('aria-live')).toBe('polite');
|
||||
expect(liveRegion?.getAttribute('role')).toBe('status');
|
||||
});
|
||||
|
||||
it('should announce changes to screen readers', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
a11y: {
|
||||
enableScreenReaderAnnouncements: true
|
||||
}
|
||||
});
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live]');
|
||||
|
||||
// Trigger navigation
|
||||
const changeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
|
||||
.find(call => call[0] === 'change')?.[1];
|
||||
|
||||
if (changeHandler) {
|
||||
changeHandler();
|
||||
|
||||
// Check if announcement was made
|
||||
expect(liveRegion?.textContent).toContain('Image 1 of 5');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should handle arrow key navigation', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Simulate arrow key presses
|
||||
const leftArrow = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
|
||||
const rightArrow = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||
|
||||
document.dispatchEvent(leftArrow);
|
||||
expect(mockPhotoSwipe.prev).toHaveBeenCalled();
|
||||
|
||||
document.dispatchEvent(rightArrow);
|
||||
expect(mockPhotoSwipe.next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle zoom with arrow keys', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const upArrow = new KeyboardEvent('keydown', { key: 'ArrowUp' });
|
||||
const downArrow = new KeyboardEvent('keydown', { key: 'ArrowDown' });
|
||||
|
||||
document.dispatchEvent(upArrow);
|
||||
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Object),
|
||||
333
|
||||
);
|
||||
|
||||
document.dispatchEvent(downArrow);
|
||||
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should show keyboard help on ? key', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const helpKey = new KeyboardEvent('keydown', { key: '?' });
|
||||
document.dispatchEvent(helpKey);
|
||||
|
||||
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
|
||||
expect(helpDialog).toBeTruthy();
|
||||
expect(helpDialog?.getAttribute('role')).toBe('dialog');
|
||||
});
|
||||
|
||||
it('should support quick navigation with number keys', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const key3 = new KeyboardEvent('keydown', { key: '3' });
|
||||
document.dispatchEvent(key3);
|
||||
|
||||
expect(mockPhotoSwipe.goTo).toHaveBeenCalledWith(2); // 0-indexed
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Gestures', () => {
|
||||
it('should handle pinch to zoom', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Simulate pinch gesture
|
||||
const touch1 = { clientX: 100, clientY: 100, identifier: 0 };
|
||||
const touch2 = { clientX: 200, clientY: 200, identifier: 1 };
|
||||
|
||||
const touchStart = new TouchEvent('touchstart', {
|
||||
touches: [touch1, touch2] as any
|
||||
});
|
||||
|
||||
element?.dispatchEvent(touchStart);
|
||||
|
||||
// Move touches apart (zoom in)
|
||||
const touch1Move = { clientX: 50, clientY: 50, identifier: 0 };
|
||||
const touch2Move = { clientX: 250, clientY: 250, identifier: 1 };
|
||||
|
||||
const touchMove = new TouchEvent('touchmove', {
|
||||
touches: [touch1Move, touch2Move] as any
|
||||
});
|
||||
|
||||
element?.dispatchEvent(touchMove);
|
||||
|
||||
// Zoom should be triggered
|
||||
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle double tap to zoom', (done) => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
const pos = { clientX: 400, clientY: 300 };
|
||||
|
||||
// First tap
|
||||
const firstTap = new TouchEvent('touchend', {
|
||||
changedTouches: [{ ...pos, identifier: 0 }] as any
|
||||
});
|
||||
|
||||
element?.dispatchEvent(new TouchEvent('touchstart', {
|
||||
touches: [{ ...pos, identifier: 0 }] as any
|
||||
}));
|
||||
element?.dispatchEvent(firstTap);
|
||||
|
||||
// Second tap within double tap delay
|
||||
setTimeout(() => {
|
||||
element?.dispatchEvent(new TouchEvent('touchstart', {
|
||||
touches: [{ ...pos, identifier: 0 }] as any
|
||||
}));
|
||||
|
||||
const secondTap = new TouchEvent('touchend', {
|
||||
changedTouches: [{ ...pos, identifier: 0 }] as any
|
||||
});
|
||||
element?.dispatchEvent(secondTap);
|
||||
|
||||
// Check zoom was triggered
|
||||
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
it('should detect swipe gestures', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Simulate swipe left
|
||||
const touchStart = new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 300, clientY: 300, identifier: 0 }] as any
|
||||
});
|
||||
|
||||
const touchEnd = new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 100, clientY: 300, identifier: 0 }] as any
|
||||
});
|
||||
|
||||
element?.dispatchEvent(touchStart);
|
||||
element?.dispatchEvent(touchEnd);
|
||||
|
||||
// Should navigate to next image
|
||||
expect(mockPhotoSwipe.next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Management', () => {
|
||||
it('should trap focus within gallery', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Add focusable elements
|
||||
const button1 = document.createElement('button');
|
||||
const button2 = document.createElement('button');
|
||||
element?.appendChild(button1);
|
||||
element?.appendChild(button2);
|
||||
|
||||
// Focus first button
|
||||
button1.focus();
|
||||
|
||||
// Simulate Tab on last focusable element
|
||||
const tabEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
shiftKey: false
|
||||
});
|
||||
|
||||
button2.focus();
|
||||
element?.dispatchEvent(tabEvent);
|
||||
|
||||
// Focus should wrap to first element
|
||||
expect(document.activeElement).toBe(button1);
|
||||
});
|
||||
|
||||
it('should restore focus on close', () => {
|
||||
const originalFocus = document.createElement('button');
|
||||
document.body.appendChild(originalFocus);
|
||||
originalFocus.focus();
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Trigger close handler
|
||||
const closeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
|
||||
.find(call => call[0] === 'close')?.[1];
|
||||
|
||||
if (closeHandler) {
|
||||
closeHandler();
|
||||
|
||||
// Focus should be restored
|
||||
expect(document.activeElement).toBe(originalFocus);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARIA Attributes', () => {
|
||||
it('should add proper ARIA attributes to gallery', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
expect(element?.getAttribute('role')).toBe('dialog');
|
||||
expect(element?.getAttribute('aria-label')).toContain('Image gallery');
|
||||
expect(element?.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('should label controls for screen readers', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Add mock controls
|
||||
const prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'pswp__button--arrow--prev';
|
||||
element?.appendChild(prevBtn);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'pswp__button--arrow--next';
|
||||
element?.appendChild(nextBtn);
|
||||
|
||||
// Enhance again to label the newly added controls
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
expect(prevBtn.getAttribute('aria-label')).toBe('Previous image');
|
||||
expect(nextBtn.getAttribute('aria-label')).toBe('Next image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile UI Adaptations', () => {
|
||||
it('should ensure minimum touch target size', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
mobileUI: {
|
||||
minTouchTargetSize: 44
|
||||
}
|
||||
});
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Add a button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pswp__button';
|
||||
button.style.width = '30px';
|
||||
button.style.height = '30px';
|
||||
element?.appendChild(button);
|
||||
|
||||
// Enhance to apply minimum sizes
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Button should be resized to meet minimum
|
||||
expect(button.style.minWidth).toBe('44px');
|
||||
expect(button.style.minHeight).toBe('44px');
|
||||
});
|
||||
|
||||
it('should add swipe indicators for mobile', () => {
|
||||
// Mock as mobile device
|
||||
Object.defineProperty(window, 'ontouchstart', {
|
||||
value: () => {},
|
||||
writable: true
|
||||
});
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
mobileUI: {
|
||||
swipeIndicators: true
|
||||
}
|
||||
});
|
||||
|
||||
const indicators = document.querySelector('.photoswipe-swipe-indicators');
|
||||
expect(indicators).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Optimizations', () => {
|
||||
it('should adapt quality based on device capabilities', () => {
|
||||
// Mock low memory device
|
||||
Object.defineProperty(navigator, 'deviceMemory', {
|
||||
value: 1,
|
||||
writable: true
|
||||
});
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
performance: {
|
||||
adaptiveQuality: true
|
||||
}
|
||||
});
|
||||
|
||||
// Service should detect low memory and adjust settings
|
||||
expect(mobileA11yService).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply reduced motion preferences', () => {
|
||||
// Mock reduced motion preference
|
||||
const mockMatchMedia = jest.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn()
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: mockMatchMedia,
|
||||
writable: true
|
||||
});
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Animations should be disabled
|
||||
expect(mockPhotoSwipe.options.showAnimationDuration).toBe(0);
|
||||
expect(mockPhotoSwipe.options.hideAnimationDuration).toBe(0);
|
||||
});
|
||||
|
||||
it('should optimize for battery saving', () => {
|
||||
// Mock battery API
|
||||
const mockBattery = {
|
||||
charging: false,
|
||||
level: 0.15,
|
||||
addEventListener: jest.fn()
|
||||
};
|
||||
|
||||
(navigator as any).getBattery = jest.fn().mockResolvedValue(mockBattery);
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
performance: {
|
||||
batteryOptimization: true
|
||||
}
|
||||
});
|
||||
|
||||
// Battery optimization should be enabled
|
||||
expect((navigator as any).getBattery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('High Contrast Mode', () => {
|
||||
it('should apply high contrast styles when enabled', () => {
|
||||
// Mock high contrast preference
|
||||
const mockMatchMedia = jest.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-contrast: high)',
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn()
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: mockMatchMedia,
|
||||
writable: true
|
||||
});
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Should have high contrast styles
|
||||
expect(element?.style.outline).toContain('2px solid white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Haptic Feedback', () => {
|
||||
it('should trigger haptic feedback on supported devices', () => {
|
||||
// Mock vibration API
|
||||
const mockVibrate = jest.fn();
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
value: mockVibrate,
|
||||
writable: true
|
||||
});
|
||||
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
|
||||
touch: {
|
||||
hapticFeedback: true
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger a gesture that should cause haptic feedback
|
||||
const element = mockPhotoSwipe.template;
|
||||
|
||||
// Double tap
|
||||
const tap = new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
|
||||
});
|
||||
|
||||
element?.dispatchEvent(new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
|
||||
}));
|
||||
element?.dispatchEvent(tap);
|
||||
|
||||
// Quick second tap
|
||||
setTimeout(() => {
|
||||
element?.dispatchEvent(new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
|
||||
}));
|
||||
element?.dispatchEvent(tap);
|
||||
|
||||
// Haptic feedback should be triggered
|
||||
expect(mockVibrate).toHaveBeenCalled();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Updates', () => {
|
||||
it('should update configuration dynamically', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Update configuration
|
||||
mobileA11yService.updateConfig({
|
||||
a11y: {
|
||||
ariaLiveRegion: 'assertive'
|
||||
},
|
||||
touch: {
|
||||
hapticFeedback: false
|
||||
}
|
||||
});
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live]');
|
||||
expect(liveRegion?.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should properly cleanup resources', () => {
|
||||
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
|
||||
|
||||
// Create some elements
|
||||
const liveRegion = document.querySelector('[aria-live]');
|
||||
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
|
||||
|
||||
expect(liveRegion).toBeTruthy();
|
||||
|
||||
// Cleanup
|
||||
mobileA11yService.cleanup();
|
||||
|
||||
// Elements should be removed
|
||||
expect(document.querySelector('[aria-live]')).toBeFalsy();
|
||||
expect(document.querySelector('.photoswipe-keyboard-help')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,11 +107,11 @@ function makeToast(message: Message, title: string, text: string): ToastOptions
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "protectNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "protectNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtecting = message.data.protect;
|
||||
const isProtecting = message.data?.protect;
|
||||
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
|
||||
|
||||
if (message.type === "taskError") {
|
||||
|
||||
@@ -10,6 +10,10 @@ let leftInstance: ReturnType<typeof Split> | null;
|
||||
let rightPaneWidth: number;
|
||||
let rightInstance: ReturnType<typeof Split> | null;
|
||||
|
||||
const noteSplitMap = new Map<string[], ReturnType<typeof Split> | undefined>(); // key: a group of ntxIds, value: the corresponding Split instance
|
||||
const noteSplitRafMap = new Map<string[], number>();
|
||||
let splitNoteContainer: HTMLElement | undefined;
|
||||
|
||||
function setupLeftPaneResizer(leftPaneVisible: boolean) {
|
||||
if (leftInstance) {
|
||||
leftInstance.destroy();
|
||||
@@ -83,7 +87,86 @@ function setupRightPaneResizer() {
|
||||
}
|
||||
}
|
||||
|
||||
function findKeyByNtxId(ntxId: string): string[] | undefined {
|
||||
// Find the corresponding key in noteSplitMap based on ntxId
|
||||
for (const key of noteSplitMap.keys()) {
|
||||
if (key.includes(ntxId)) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setupNoteSplitResizer(ntxIds: string[]) {
|
||||
let targetNtxIds: string[] | undefined;
|
||||
for (const ntxId of ntxIds) {
|
||||
targetNtxIds = findKeyByNtxId(ntxId);
|
||||
if (targetNtxIds) break;
|
||||
}
|
||||
|
||||
if (targetNtxIds) {
|
||||
noteSplitMap.get(targetNtxIds)?.destroy();
|
||||
for (const id of ntxIds) {
|
||||
if (!targetNtxIds.includes(id)) {
|
||||
targetNtxIds.push(id)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
targetNtxIds = [...ntxIds];
|
||||
}
|
||||
noteSplitMap.set(targetNtxIds, undefined);
|
||||
createSplitInstance(targetNtxIds);
|
||||
}
|
||||
|
||||
|
||||
function delNoteSplitResizer(ntxIds: string[]) {
|
||||
let targetNtxIds = findKeyByNtxId(ntxIds[0]);
|
||||
if (!targetNtxIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
noteSplitMap.get(targetNtxIds)?.destroy();
|
||||
noteSplitMap.delete(targetNtxIds);
|
||||
targetNtxIds = targetNtxIds.filter(id => !ntxIds.includes(id));
|
||||
|
||||
if (targetNtxIds.length >= 2) {
|
||||
noteSplitMap.set(targetNtxIds, undefined);
|
||||
createSplitInstance(targetNtxIds);
|
||||
}
|
||||
}
|
||||
|
||||
function moveNoteSplitResizer(ntxId: string) {
|
||||
const targetNtxIds = findKeyByNtxId(ntxId);
|
||||
if (!targetNtxIds) {
|
||||
return;
|
||||
}
|
||||
noteSplitMap.get(targetNtxIds)?.destroy();
|
||||
noteSplitMap.set(targetNtxIds, undefined);
|
||||
createSplitInstance(targetNtxIds);
|
||||
}
|
||||
|
||||
function createSplitInstance(targetNtxIds: string[]) {
|
||||
const prevRafId = noteSplitRafMap.get(targetNtxIds);
|
||||
if (prevRafId) {
|
||||
cancelAnimationFrame(prevRafId);
|
||||
}
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
splitNoteContainer = splitNoteContainer ?? $("#center-pane").find(".split-note-container-widget")[0];
|
||||
const splitPanels = [...splitNoteContainer.querySelectorAll<HTMLElement>(':scope > .note-split')]
|
||||
.filter(el => targetNtxIds.includes(el.getAttribute('data-ntx-id') ?? ""));
|
||||
const splitInstance = Split(splitPanels, {
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
minSize: 150,
|
||||
});
|
||||
noteSplitMap.set(targetNtxIds, splitInstance);
|
||||
noteSplitRafMap.delete(targetNtxIds);
|
||||
});
|
||||
noteSplitRafMap.set(targetNtxIds, rafId);
|
||||
}
|
||||
|
||||
export default {
|
||||
setupLeftPaneResizer,
|
||||
setupRightPaneResizer
|
||||
setupRightPaneResizer,
|
||||
setupNoteSplitResizer,
|
||||
delNoteSplitResizer,
|
||||
moveNoteSplitResizer
|
||||
};
|
||||
|
||||
@@ -218,7 +218,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
|
||||
if (utils.isElectron()) {
|
||||
const ipc = utils.dynamicRequire("electron").ipcRenderer;
|
||||
|
||||
ipc.on("server-response", async (event: string, arg: Arg) => {
|
||||
ipc.on("server-response", async (_, arg: Arg) => {
|
||||
if (arg.statusCode >= 200 && arg.statusCode < 300) {
|
||||
handleSuccessfulResponse(arg);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
@@ -119,11 +119,6 @@ describe("shortcuts", () => {
|
||||
metaKey: options.metaKey || false
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match simple key shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match shortcuts with modifiers", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
|
||||
@@ -148,6 +143,28 @@ describe("shortcuts", () => {
|
||||
expect(matchesShortcut(event, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not match when no modifiers are used", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match some keys even with no modifiers", () => {
|
||||
// Bare function keys
|
||||
let event = createKeyboardEvent({ key: "F1", code: "F1" });
|
||||
expect(matchesShortcut(event, "F1")).toBeTruthy();
|
||||
expect(matchesShortcut(event, "f1")).toBeTruthy();
|
||||
|
||||
// Function keys with shift
|
||||
event = createKeyboardEvent({ key: "F1", code: "F1", shiftKey: true });
|
||||
expect(matchesShortcut(event, "Shift+F1")).toBeTruthy();
|
||||
|
||||
// Special keys
|
||||
for (const keyCode of [ "Delete", "Enter" ]) {
|
||||
event = createKeyboardEvent({ key: keyCode, code: keyCode });
|
||||
expect(matchesShortcut(event, keyCode), `Key ${keyCode}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle alternative modifier names", () => {
|
||||
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
|
||||
@@ -320,4 +337,36 @@ describe("shortcuts", () => {
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIMEComposing', () => {
|
||||
it('should return true when event.isComposing is true', () => {
|
||||
const event = { isComposing: true, keyCode: 65 } as KeyboardEvent;
|
||||
expect(isIMEComposing(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when keyCode is 229', () => {
|
||||
const event = { isComposing: false, keyCode: 229 } as KeyboardEvent;
|
||||
expect(isIMEComposing(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when both isComposing is true and keyCode is 229', () => {
|
||||
const event = { isComposing: true, keyCode: 229 } as KeyboardEvent;
|
||||
expect(isIMEComposing(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal keys', () => {
|
||||
const event = { isComposing: false, keyCode: 65 } as KeyboardEvent;
|
||||
expect(isIMEComposing(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when isComposing is undefined and keyCode is not 229', () => {
|
||||
const event = { keyCode: 13 } as KeyboardEvent;
|
||||
expect(isIMEComposing(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined events gracefully', () => {
|
||||
expect(isIMEComposing(null as any)).toBe(false);
|
||||
expect(isIMEComposing(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,59 @@ interface ShortcutBinding {
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
const functionKeyCodes: string[] = [];
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
const keyCode = `F${i}`;
|
||||
functionKeyCodes.push(keyCode);
|
||||
keyMap[`f${i}`] = [ keyCode ];
|
||||
}
|
||||
|
||||
const KEYCODES_WITH_NO_MODIFIER = new Set([
|
||||
"Delete",
|
||||
"Enter",
|
||||
...functionKeyCodes
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if IME (Input Method Editor) is composing
|
||||
* This is used to prevent keyboard shortcuts from firing during IME composition
|
||||
* @param e - The keyboard event to check
|
||||
* @returns true if IME is currently composing, false otherwise
|
||||
*/
|
||||
export function isIMEComposing(e: KeyboardEvent): boolean {
|
||||
// Handle null/undefined events gracefully
|
||||
if (!e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Standard check for composition state
|
||||
// e.isComposing is true when IME is actively composing
|
||||
// e.keyCode === 229 is a fallback for older browsers where 229 indicates IME processing
|
||||
return e.isComposing || e.keyCode === 229;
|
||||
}
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
}
|
||||
@@ -42,6 +95,13 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
}
|
||||
|
||||
const e = evt as KeyboardEvent;
|
||||
|
||||
// Skip processing if IME is composing to prevent shortcuts from
|
||||
// interfering with text input in CJK languages
|
||||
if (isIMEComposing(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, keyboardShortcut)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -111,6 +171,12 @@ export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||
const expectedShift = modifiers.includes('shift');
|
||||
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
|
||||
|
||||
// Refuse key combinations that don't include modifiers because they interfere with the normal usage of the application.
|
||||
// Some keys such as function keys are an exception.
|
||||
if (!(expectedCtrl || expectedAlt || expectedShift || expectedMeta) && !KEYCODES_WITH_NO_MODIFIER.has(e.code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return e.ctrlKey === expectedCtrl &&
|
||||
e.altKey === expectedAlt &&
|
||||
e.shiftKey === expectedShift &&
|
||||
@@ -124,32 +190,6 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
const mappedKeys = keyMap[key.toLowerCase()];
|
||||
if (mappedKeys) {
|
||||
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
||||
@@ -163,7 +203,7 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
return e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
export interface ToastOptions {
|
||||
id?: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
delay?: number;
|
||||
autohide?: boolean;
|
||||
@@ -12,20 +11,32 @@ export interface ToastOptions {
|
||||
}
|
||||
|
||||
function toast(options: ToastOptions) {
|
||||
const $toast = $(
|
||||
`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
const $toast = $(options.title
|
||||
? `\
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
<span class="bx bx-${options.icon}"></span>
|
||||
<span class="toast-title"></span>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>`
|
||||
: `
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-icon">
|
||||
<span class="bx bx-${options.icon}"></span>
|
||||
<span class="toast-title"></span>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>`
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
<div class="toast-header">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>`
|
||||
);
|
||||
|
||||
$toast.find(".toast-title").text(options.title);
|
||||
$toast.toggleClass("no-title", !options.title);
|
||||
$toast.find(".toast-title").text(options.title ?? "");
|
||||
$toast.find(".toast-body").html(options.message);
|
||||
|
||||
if (options.id) {
|
||||
@@ -70,7 +81,6 @@ function showMessage(message: string, delay = 2000) {
|
||||
console.debug(utils.now(), "message:", message);
|
||||
|
||||
toast({
|
||||
title: "Info",
|
||||
icon: "check",
|
||||
message: message,
|
||||
autohide: true,
|
||||
@@ -82,7 +92,6 @@ export function showError(message: string, delay = 10000) {
|
||||
console.log(utils.now(), "error: ", message);
|
||||
|
||||
toast({
|
||||
title: "Error",
|
||||
icon: "alert",
|
||||
message: message,
|
||||
autohide: true,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ViewScope } from "./link.js";
|
||||
import FNote from "../entities/fnote";
|
||||
|
||||
const SVG_MIME = "image/svg+xml";
|
||||
|
||||
export const isShare = !window.glob;
|
||||
|
||||
function reloadFrontendApp(reason?: string) {
|
||||
export function reloadFrontendApp(reason?: string) {
|
||||
if (reason) {
|
||||
logInfo(`Frontend app reload: ${reason}`);
|
||||
}
|
||||
@@ -13,7 +14,7 @@ function reloadFrontendApp(reason?: string) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function restartDesktopApp() {
|
||||
export function restartDesktopApp() {
|
||||
if (!isElectron()) {
|
||||
reloadFrontendApp();
|
||||
return;
|
||||
@@ -46,27 +47,6 @@ function parseDate(str: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/30465299/4898894
|
||||
function getMonthsInDateRange(startDate: string, endDate: string) {
|
||||
const start = startDate.split("-");
|
||||
const end = endDate.split("-");
|
||||
const startYear = parseInt(start[0]);
|
||||
const endYear = parseInt(end[0]);
|
||||
const dates: string[] = [];
|
||||
|
||||
for (let i = startYear; i <= endYear; i++) {
|
||||
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
|
||||
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
|
||||
|
||||
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
|
||||
const month = j + 1;
|
||||
const displayMonth = month < 10 ? "0" + month : month;
|
||||
dates.push([i, displayMonth].join("-"));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function padNum(num: number) {
|
||||
return `${num <= 9 ? "0" : ""}${num}`;
|
||||
}
|
||||
@@ -125,7 +105,7 @@ function formatDateISO(date: Date) {
|
||||
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
export function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
if (userSuppliedFormat?.trim()) {
|
||||
return dayjs(date).format(userSuppliedFormat);
|
||||
} else {
|
||||
@@ -144,11 +124,23 @@ function now() {
|
||||
/**
|
||||
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
||||
*/
|
||||
function isElectron() {
|
||||
export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
export function isPWA() {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.matchMedia('(display-mode: window-controls-overlay)').matches
|
||||
|| window.navigator.standalone
|
||||
|| window.navigator.windowControlsOverlay
|
||||
);
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
return navigator.platform.indexOf("Mac") > -1;
|
||||
}
|
||||
|
||||
@@ -185,7 +177,11 @@ export function escapeQuotes(value: string) {
|
||||
return value.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function formatSize(size: number) {
|
||||
export function formatSize(size: number | null | undefined) {
|
||||
if (size === null || size === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
@@ -218,7 +214,7 @@ function randomString(len: number) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
export function isMobile() {
|
||||
return (
|
||||
window.glob?.device === "mobile" ||
|
||||
// window.glob.device is not available in setup
|
||||
@@ -292,7 +288,55 @@ function isHtmlEmpty(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function clearBrowserCache() {
|
||||
function formatHtml(html: string) {
|
||||
let indent = "\n";
|
||||
const tab = "\t";
|
||||
let i = 0;
|
||||
let pre: { indent: string; tag: string }[] = [];
|
||||
|
||||
html = html
|
||||
.replace(new RegExp("<pre>([\\s\\S]+?)?</pre>"), function (x) {
|
||||
pre.push({ indent: "", tag: x });
|
||||
return "<--TEMPPRE" + i++ + "/-->";
|
||||
})
|
||||
.replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) {
|
||||
let ret;
|
||||
const tagRegEx = /<\/?([^\s/>]+)/.exec(x);
|
||||
let tag = tagRegEx ? tagRegEx[1] : "";
|
||||
let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x);
|
||||
|
||||
if (p) {
|
||||
const pInd = parseInt(p[1]);
|
||||
pre[pInd].indent = indent;
|
||||
}
|
||||
|
||||
if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) {
|
||||
// self closing tag
|
||||
ret = indent + x;
|
||||
} else {
|
||||
if (x.indexOf("</") < 0) {
|
||||
//open tag
|
||||
if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length);
|
||||
else ret = indent + x;
|
||||
!p && (indent += tab);
|
||||
} else {
|
||||
//close tag
|
||||
indent = indent.substr(0, indent.length - 1);
|
||||
if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length);
|
||||
else ret = indent + x;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
for (i = pre.length; i--;) {
|
||||
html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>"));
|
||||
}
|
||||
|
||||
return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html;
|
||||
}
|
||||
|
||||
export async function clearBrowserCache() {
|
||||
if (isElectron()) {
|
||||
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
await win.webContents.session.clearCache();
|
||||
@@ -306,7 +350,13 @@ function copySelectionToClipboard() {
|
||||
}
|
||||
}
|
||||
|
||||
function dynamicRequire(moduleName: string) {
|
||||
type dynamicRequireMappings = {
|
||||
"@electron/remote": typeof import("@electron/remote"),
|
||||
"electron": typeof import("electron"),
|
||||
"child_process": typeof import("child_process")
|
||||
};
|
||||
|
||||
export function dynamicRequire<T extends keyof dynamicRequireMappings>(moduleName: T): Awaited<dynamicRequireMappings[T]>{
|
||||
if (typeof __non_webpack_require__ !== "undefined") {
|
||||
return __non_webpack_require__(moduleName);
|
||||
} else {
|
||||
@@ -374,33 +424,42 @@ async function openInAppHelp($button: JQuery<HTMLElement>) {
|
||||
|
||||
const inAppHelpPage = $button.attr("data-in-app-help");
|
||||
if (inAppHelpPage) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
openInAppHelpFromUrl(inAppHelpPage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the in-app help at the given page in a split note. If there already is a split note open with a help page, it will be replaced by this one.
|
||||
*
|
||||
* @param inAppHelpPage the ID of the help note (excluding the `_help_` prefix).
|
||||
* @returns a promise that resolves once the help has been opened.
|
||||
*/
|
||||
export async function openInAppHelpFromUrl(inAppHelpPage: string) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
||||
@@ -428,7 +487,7 @@ function sleep(time_ms: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string) {
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
@@ -561,8 +620,7 @@ function copyHtmlToClipboard(content: string) {
|
||||
document.removeEventListener("copy", listener);
|
||||
}
|
||||
|
||||
// TODO: Set to FNote once the file is ported.
|
||||
function createImageSrcUrl(note: { noteId: string; title: string }) {
|
||||
export function createImageSrcUrl(note: FNote) {
|
||||
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
|
||||
}
|
||||
|
||||
@@ -731,16 +789,91 @@ function isUpdateAvailable(latestVersion: string | null | undefined, currentVers
|
||||
return compareVersions(latestVersion, currentVersion) > 0;
|
||||
}
|
||||
|
||||
function isLaunchBarConfig(noteId: string) {
|
||||
export function isLaunchBarConfig(noteId: string) {
|
||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a class to the <body> of the page, where the class name is formed via a prefix and a value.
|
||||
* Useful for configurable options such as `heading-style-markdown`, where `heading-style` is the prefix and `markdown` is the dynamic value.
|
||||
* There is no separator between the prefix and the value, if needed it has to be supplied manually to the prefix.
|
||||
*
|
||||
* @param prefix the prefix.
|
||||
* @param value the value to be appended to the prefix.
|
||||
*/
|
||||
export function toggleBodyClass(prefix: string, value: string) {
|
||||
const $body = $("body");
|
||||
for (const clazz of Array.from($body[0].classList)) {
|
||||
// create copy to safely iterate over while removing classes
|
||||
if (clazz.startsWith(prefix)) {
|
||||
$body.removeClass(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
$body.addClass(prefix + value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic comparison for equality between the two arrays. The values are strictly checked via `===`.
|
||||
*
|
||||
* @param a the first array to compare.
|
||||
* @param b the second array to compare.
|
||||
* @returns `true` if both arrays are equals, `false` otherwise.
|
||||
*/
|
||||
export function arrayEqual<T>(a: T[], b: T[]) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i=0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type Indexed<T extends object> = T & { index: number };
|
||||
|
||||
/**
|
||||
* Given an object array, alters every object in the array to have an index field assigned to it.
|
||||
*
|
||||
* @param items the objects to be numbered.
|
||||
* @returns the same object for convenience, with the type changed to indicate the new index field.
|
||||
*/
|
||||
export function numberObjectsInPlace<T extends object>(items: T[]): Indexed<T>[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
(item as Indexed<T>).index = index++;
|
||||
}
|
||||
return items as Indexed<T>[];
|
||||
}
|
||||
|
||||
export function mapToKeyValueArray<K extends string | number | symbol, V>(map: Record<K, V>) {
|
||||
const values: { key: K, value: V }[] = [];
|
||||
for (const [ key, value ] of Object.entries(map)) {
|
||||
values.push({ key: key as K, value: value as V });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getErrorMessage(e: unknown) {
|
||||
if (e && typeof e === "object" && "message" in e && typeof e.message === "string") {
|
||||
return e.message;
|
||||
} else {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
reloadTray,
|
||||
parseDate,
|
||||
getMonthsInDateRange,
|
||||
formatDateISO,
|
||||
formatDateTime,
|
||||
formatTimeInterval,
|
||||
@@ -748,6 +881,7 @@ export default {
|
||||
localNowDateTime,
|
||||
now,
|
||||
isElectron,
|
||||
isPWA,
|
||||
isMac,
|
||||
isCtrlKey,
|
||||
assertArguments,
|
||||
@@ -760,6 +894,7 @@ export default {
|
||||
getNoteTypeClass,
|
||||
getMimeTypeClass,
|
||||
isHtmlEmpty,
|
||||
formatHtml,
|
||||
clearBrowserCache,
|
||||
copySelectionToClipboard,
|
||||
dynamicRequire,
|
||||
|
||||
@@ -6,9 +6,11 @@ import frocaUpdater from "./froca_updater.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import toast from "./toast.js";
|
||||
|
||||
type MessageHandler = (message: any) => void;
|
||||
const messageHandlers: MessageHandler[] = [];
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
let messageHandlers: MessageHandler[] = [];
|
||||
|
||||
let ws: WebSocket;
|
||||
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
|
||||
@@ -47,10 +49,14 @@ function logInfo(message: string) {
|
||||
window.logError = logError;
|
||||
window.logInfo = logInfo;
|
||||
|
||||
function subscribeToMessages(messageHandler: MessageHandler) {
|
||||
export function subscribeToMessages(messageHandler: MessageHandler) {
|
||||
messageHandlers.push(messageHandler);
|
||||
}
|
||||
|
||||
export function unsubscribeToMessage(messageHandler: MessageHandler) {
|
||||
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
|
||||
}
|
||||
|
||||
// used to serialize frontend update operations
|
||||
let consumeQueuePromise: Promise<void> | null = null;
|
||||
|
||||
@@ -273,13 +279,17 @@ function connectWebSocket() {
|
||||
|
||||
async function sendPing() {
|
||||
if (Date.now() - lastPingTs > 30000) {
|
||||
console.log(
|
||||
utils.now(),
|
||||
"Lost websocket connection to the backend. If you keep having this issue repeatedly, you might want to check your reverse proxy (nginx, apache) configuration and allow/unblock WebSocket."
|
||||
);
|
||||
console.warn(utils.now(), "Lost websocket connection to the backend");
|
||||
toast.showPersistent({
|
||||
id: "lost-websocket-connection",
|
||||
title: t("ws.lost-websocket-connection-title"),
|
||||
message: t("ws.lost-websocket-connection-message"),
|
||||
icon: "no-signal"
|
||||
});
|
||||
}
|
||||
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
toast.closePersistent("lost-websocket-connection");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "ping",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./stylesheets/auth.css";
|
||||
|
||||
// @TriliumNextTODO: is this even needed anymore?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "jquery";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "normalize.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "@triliumnext/ckeditor5/content.css";
|
||||
import "@triliumnext/ckeditor5/src/theme/ck-content.css";
|
||||
import "@triliumnext/share-theme/styles/index.css";
|
||||
import "@triliumnext/share-theme/scripts/index.js";
|
||||
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* Gallery styles for PhotoSwipe integration
|
||||
* Provides styling for gallery UI elements
|
||||
*/
|
||||
|
||||
/* Gallery thumbnail strip */
|
||||
.gallery-thumbnail-strip {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-thumbnail.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gallery controls animations */
|
||||
.gallery-slideshow-controls button {
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.gallery-slideshow-controls button:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(255, 255, 255, 1) !important;
|
||||
}
|
||||
|
||||
.gallery-slideshow-controls button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Slideshow progress indicator */
|
||||
.slideshow-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.slideshow-progress-bar {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 0;
|
||||
transition: width linear;
|
||||
}
|
||||
|
||||
.slideshow-progress.active .slideshow-progress-bar {
|
||||
animation: slideshow-progress var(--slideshow-interval) linear;
|
||||
}
|
||||
|
||||
@keyframes slideshow-progress {
|
||||
from { width: 0; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
/* Gallery counter styling */
|
||||
.gallery-counter {
|
||||
font-family: var(--font-family-monospace);
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gallery-counter .current-index {
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gallery-counter .total-count {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Enhanced image hover effects */
|
||||
.pswp__img {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.pswp__img.pswp__img--zoomed {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* Gallery navigation arrows */
|
||||
.pswp__button--arrow--left,
|
||||
.pswp__button--arrow--right {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.pswp__button--arrow--left:hover,
|
||||
.pswp__button--arrow--right:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap areas */
|
||||
@media (pointer: coarse) {
|
||||
.gallery-thumbnail {
|
||||
min-width: 60px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.gallery-slideshow-controls button {
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.pswp--animate_opacity {
|
||||
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.pswp__bg {
|
||||
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.pswp__preloader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pswp__preloader__icn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: gallery-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gallery-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.pswp__error-msg {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #ff6b6b;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.pswp__button:focus-visible {
|
||||
outline: 2px solid #4a9eff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail:focus-visible {
|
||||
outline: 2px solid #4a9eff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
body.theme-dark .gallery-thumbnail-strip {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
body.theme-dark .gallery-slideshow-controls button {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
body.theme-dark .gallery-slideshow-controls button:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
body.theme-light .pswp__bg {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.theme-light .gallery-counter,
|
||||
body.theme-light .gallery-keyboard-hints {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-thumbnail-strip {
|
||||
bottom: 40px;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
}
|
||||
|
||||
.gallery-slideshow-controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.gallery-counter {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.pswp__button--arrow--left,
|
||||
.pswp__button--arrow--right {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet-specific styles */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.gallery-thumbnail-strip {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
width: 70px !important;
|
||||
height: 70px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High-DPI display optimizations */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.gallery-thumbnail img {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gallery-thumbnail,
|
||||
.gallery-slideshow-controls button,
|
||||
.pswp__img,
|
||||
.pswp--animate_opacity,
|
||||
.pswp__bg {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.gallery-thumbnail-strip,
|
||||
.gallery-slideshow-controls,
|
||||
.gallery-counter,
|
||||
.gallery-keyboard-hints {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* PhotoSwipe Mobile & Accessibility Styles
|
||||
* Phase 6: Complete mobile optimization and WCAG 2.1 AA compliance
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Touch Target Optimization (WCAG 2.1 Success Criterion 2.5.5)
|
||||
========================================================================== */
|
||||
|
||||
/* Ensure all interactive elements meet minimum 44x44px touch target */
|
||||
.pswp__button,
|
||||
.pswp__button--arrow--left,
|
||||
.pswp__button--arrow--right,
|
||||
.pswp__button--close,
|
||||
.pswp__button--zoom,
|
||||
.pswp__button--fs,
|
||||
.gallery-thumbnail,
|
||||
.photoswipe-bottom-sheet button {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Increase touch target padding on mobile */
|
||||
@media (pointer: coarse) {
|
||||
.pswp__button {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Larger hit areas for navigation arrows */
|
||||
.pswp__button--arrow--left,
|
||||
.pswp__button--arrow--right {
|
||||
width: 60px !important;
|
||||
height: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Focus Indicators (WCAG 2.1 Success Criterion 2.4.7)
|
||||
========================================================================== */
|
||||
|
||||
/* High visibility focus indicators */
|
||||
.pswp__button:focus,
|
||||
.pswp__button:focus-visible,
|
||||
.gallery-thumbnail:focus,
|
||||
.photoswipe-bottom-sheet button:focus,
|
||||
.photoswipe-focused {
|
||||
outline: 3px solid #4A90E2 !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.3);
|
||||
}
|
||||
|
||||
/* Remove default browser outline */
|
||||
.pswp__button:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Focus indicator for images */
|
||||
.pswp__img:focus {
|
||||
outline: 3px solid #4A90E2;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* Skip link styles */
|
||||
.photoswipe-skip-link {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
z-index: 100000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.photoswipe-skip-link:focus {
|
||||
left: 10px !important;
|
||||
top: 10px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Mobile UI Adaptations
|
||||
========================================================================== */
|
||||
|
||||
/* Mobile-optimized toolbar */
|
||||
@media (max-width: 768px) {
|
||||
.pswp__top-bar {
|
||||
height: 60px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Reposition counter for mobile */
|
||||
.pswp__counter {
|
||||
top: auto;
|
||||
bottom: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom sheet for mobile controls */
|
||||
.photoswipe-bottom-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
padding: env(safe-area-inset-bottom, 20px) 20px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.photoswipe-bottom-sheet.active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.photoswipe-bottom-sheet button {
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.photoswipe-bottom-sheet button:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Swipe indicators */
|
||||
.photoswipe-swipe-indicators {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
animation: fadeInOut 3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0; }
|
||||
20%, 80% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Gesture hints */
|
||||
.photoswipe-gesture-hints {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Context menu for long press */
|
||||
.photoswipe-context-menu {
|
||||
background: var(--theme-background-color, white);
|
||||
border: 1px solid var(--theme-border-color, #ccc);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.photoswipe-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photoswipe-context-menu button:hover,
|
||||
.photoswipe-context-menu button:focus {
|
||||
background: var(--theme-hover-background, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Breakpoints
|
||||
========================================================================== */
|
||||
|
||||
/* Small phones (< 375px) */
|
||||
@media (max-width: 374px) {
|
||||
.pswp__button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip {
|
||||
padding: 5px !important;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets (768px - 1024px) */
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.pswp__button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
width: 90px !important;
|
||||
height: 90px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation adjustments */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.pswp__top-bar {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip {
|
||||
bottom: 40px !important;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Accessibility Enhancements
|
||||
========================================================================== */
|
||||
|
||||
/* Screen reader only content */
|
||||
.photoswipe-sr-only,
|
||||
.photoswipe-live-region,
|
||||
.photoswipe-aria-live {
|
||||
position: absolute !important;
|
||||
left: -10000px !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Keyboard help dialog */
|
||||
.photoswipe-keyboard-help {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--theme-background-color, white);
|
||||
color: var(--theme-text-color, black);
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10001;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help dl {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help dt {
|
||||
float: left;
|
||||
clear: left;
|
||||
width: 120px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help dd {
|
||||
margin-left: 140px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help kbd {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background: var(--theme-kbd-background, #f0f0f0);
|
||||
border: 1px solid var(--theme-border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help .close-help {
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--theme-primary-color, #4A90E2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help .close-help:hover,
|
||||
.photoswipe-keyboard-help .close-help:focus {
|
||||
background: var(--theme-primary-hover, #357ABD);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
High Contrast Mode Support
|
||||
========================================================================== */
|
||||
|
||||
@media (prefers-contrast: high) {
|
||||
.pswp__bg {
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
background: #000 !important;
|
||||
border: 2px solid #fff !important;
|
||||
}
|
||||
|
||||
.pswp__button svg {
|
||||
fill: #fff !important;
|
||||
}
|
||||
|
||||
.pswp__counter {
|
||||
background: #000 !important;
|
||||
color: #fff !important;
|
||||
border: 2px solid #fff !important;
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
border-width: 3px !important;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help {
|
||||
background: #000 !important;
|
||||
color: #fff !important;
|
||||
border: 2px solid #fff !important;
|
||||
}
|
||||
|
||||
.photoswipe-keyboard-help kbd {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border-color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Windows High Contrast Mode */
|
||||
@media (-ms-high-contrast: active) {
|
||||
.pswp__button {
|
||||
border: 2px solid WindowText !important;
|
||||
}
|
||||
|
||||
.pswp__counter {
|
||||
border: 2px solid WindowText !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced Motion Support (WCAG 2.1 Success Criterion 2.3.3)
|
||||
========================================================================== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable all animations */
|
||||
.pswp *,
|
||||
.pswp *::before,
|
||||
.pswp *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* Remove slide transitions */
|
||||
.pswp__container {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Remove zoom animations */
|
||||
.pswp__img {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Instant show/hide for indicators */
|
||||
.photoswipe-swipe-indicators,
|
||||
.photoswipe-gesture-hints {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Performance Optimizations
|
||||
========================================================================== */
|
||||
|
||||
/* GPU acceleration for smooth animations */
|
||||
.pswp__container,
|
||||
.pswp__img,
|
||||
.pswp__zoom-wrap {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Optimize rendering for low-end devices */
|
||||
@media (max-width: 768px) and (max-resolution: 2dppx) {
|
||||
.pswp__img {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduce visual complexity on low-end devices */
|
||||
.low-performance-mode .pswp__button {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.low-performance-mode .gallery-thumbnail {
|
||||
box-shadow: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Battery Optimization Mode
|
||||
========================================================================== */
|
||||
|
||||
.battery-saver-mode .pswp__img {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.battery-saver-mode .pswp__button {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.battery-saver-mode .gallery-thumbnail-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Print Styles
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
.pswp {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Custom Scrollbar for Mobile
|
||||
========================================================================== */
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Safe Area Insets (for devices with notches)
|
||||
========================================================================== */
|
||||
|
||||
.pswp__top-bar {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.photoswipe-bottom-sheet {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.pswp__button--arrow--left {
|
||||
left: env(safe-area-inset-left, 10px);
|
||||
}
|
||||
|
||||
.pswp__button--arrow--right {
|
||||
right: env(safe-area-inset-right, 10px);
|
||||
}
|
||||
2
apps/client/src/stylesheets/bootstrap.scss
vendored
2
apps/client/src/stylesheets/bootstrap.scss
vendored
@@ -1,2 +0,0 @@
|
||||
/* Import all of Bootstrap's CSS */
|
||||
@use "bootstrap/scss/bootstrap";
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* Media Viewer Styles for Trilium Notes
|
||||
* Customizes PhotoSwipe appearance to match Trilium's theme
|
||||
*/
|
||||
|
||||
/* Base PhotoSwipe container customization */
|
||||
.pswp {
|
||||
--pswp-bg: rgba(0, 0, 0, 0.95);
|
||||
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
|
||||
--pswp-icon-color: #fff;
|
||||
--pswp-icon-color-secondary: rgba(255, 255, 255, 0.75);
|
||||
--pswp-icon-stroke-color: #fff;
|
||||
--pswp-icon-stroke-width: 1px;
|
||||
--pswp-error-text-color: #f44336;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
body.theme-dark .pswp,
|
||||
body.theme-next-dark .pswp {
|
||||
--pswp-bg: rgba(0, 0, 0, 0.95);
|
||||
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
body.theme-light .pswp,
|
||||
body.theme-next-light .pswp {
|
||||
--pswp-bg: rgba(0, 0, 0, 0.9);
|
||||
--pswp-placeholder-bg: rgba(50, 50, 50, 0.8);
|
||||
}
|
||||
|
||||
/* Toolbar and controls styling */
|
||||
.pswp__top-bar {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.pswp__button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Counter styling */
|
||||
.pswp__counter {
|
||||
font-family: var(--main-font-family);
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Caption styling */
|
||||
.pswp__caption {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.pswp__caption__center {
|
||||
text-align: center;
|
||||
font-family: var(--main-font-family);
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.pswp__img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.pswp__img--placeholder {
|
||||
background-color: var(--pswp-placeholder-bg);
|
||||
}
|
||||
|
||||
.pswp--zoomed-in .pswp__img {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.pswp--dragging .pswp__img {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.pswp__preloader {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pswp__preloader__icn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Error message styling */
|
||||
.pswp__error-msg {
|
||||
font-family: var(--main-font-family);
|
||||
font-size: 14px;
|
||||
color: var(--pswp-error-text-color);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Thumbnails strip (for future implementation) */
|
||||
.pswp__thumbnails {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pswp__thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pswp__thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pswp__thumbnail--active {
|
||||
opacity: 1;
|
||||
border-color: var(--main-border-color);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.pswp--open {
|
||||
animation: pswpFadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
.pswp--closing {
|
||||
animation: pswpFadeOut 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pswpFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pswpFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoom animation */
|
||||
.pswp__zoom-wrap {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Mobile-specific adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.pswp__caption__center {
|
||||
font-size: 12px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.pswp__counter {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pswp__thumbnails {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.pswp__thumbnail {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Integration with Trilium's note context */
|
||||
.media-viewer-trigger {
|
||||
cursor: zoom-in;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.media-viewer-trigger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Gallery mode indicators */
|
||||
.media-viewer-gallery-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: var(--main-font-family);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Fullscreen mode adjustments */
|
||||
.pswp--fs {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.pswp--fs .pswp__top-bar {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.pswp__button:focus {
|
||||
outline: 2px solid var(--main-border-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pswp__img:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Custom toolbar buttons */
|
||||
.pswp__button--download {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'%3E%3C/path%3E%3Cpolyline points='7 10 12 15 17 10'%3E%3C/polyline%3E%3Cline x1='12' y1='15' x2='12' y2='3'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.pswp__button--info {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='16' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12.01' y2='8'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.pswp {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
#center-pane > *:not(.split-note-container-widget),
|
||||
#right-pane,
|
||||
.title-row .note-icon-widget,
|
||||
.title-row .button-widget,
|
||||
.title-row .icon-action,
|
||||
.ribbon-container,
|
||||
.promoted-attributes-widget,
|
||||
.scroll-padding-widget,
|
||||
|
||||
@@ -28,6 +28,28 @@
|
||||
--ck-mention-list-max-height: 500px;
|
||||
}
|
||||
|
||||
body#trilium-app.motion-disabled *,
|
||||
body#trilium-app.motion-disabled *::before,
|
||||
body#trilium-app.motion-disabled *::after {
|
||||
/* Disable transitions and animations */
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
body#trilium-app.shadows-disabled *,
|
||||
body#trilium-app.shadows-disabled *::before,
|
||||
body#trilium-app.shadows-disabled *::after {
|
||||
/* Disable shadows */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body#trilium-app.backdrop-effects-disabled *,
|
||||
body#trilium-app.backdrop-effects-disabled *::before,
|
||||
body#trilium-app.backdrop-effects-disabled *::after {
|
||||
/* Disable backdrop effects */
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
--bs-table-bg: transparent !important;
|
||||
}
|
||||
@@ -139,12 +161,27 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.form-group.disabled,
|
||||
.form-checkbox.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
label.tn-radio input[type="radio"],
|
||||
label.tn-checkbox input[type="checkbox"] {
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
#left-pane input,
|
||||
#left-pane select,
|
||||
#left-pane textarea {
|
||||
@@ -215,10 +252,6 @@ button.close:hover {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.note-title[readonly] {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.tdialog {
|
||||
display: none;
|
||||
}
|
||||
@@ -257,6 +290,11 @@ button.close:hover {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-action.btn {
|
||||
padding: 0 8px;
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.ui-widget-content a:not(.ui-tabs-anchor) {
|
||||
color: #337ab7 !important;
|
||||
}
|
||||
@@ -325,28 +363,24 @@ button kbd {
|
||||
.tabulator-popup-container {
|
||||
color: var(--menu-text-color) !important;
|
||||
font-size: inherit;
|
||||
background-color: var(--menu-background-color) !important;
|
||||
background: var(--menu-background-color) !important;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
--bs-dropdown-zindex: 999;
|
||||
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-menu .dropdown-divider {
|
||||
break-before: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
body.desktop .dropdown-menu,
|
||||
body.desktop .tabulator-popup-container {
|
||||
border: 1px solid var(--dropdown-border-color);
|
||||
column-rule: 1px solid var(--dropdown-border-color);
|
||||
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
|
||||
animation: dropdown-menu-opening 100ms ease-in;
|
||||
}
|
||||
|
||||
@supports (animation-fill-mode: forwards) {
|
||||
/* Delay the opening of submenus */
|
||||
body.desktop .dropdown-submenu .dropdown-menu {
|
||||
body.desktop:not(.motion-disabled) .dropdown-submenu .dropdown-menu {
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: var(--submenu-opening-delay);
|
||||
@@ -381,7 +415,7 @@ body.desktop .tabulator-popup-container {
|
||||
}
|
||||
|
||||
.dropdown-menu a:hover:not(.disabled),
|
||||
.dropdown-item:hover:not(.disabled, .dropdown-item-container),
|
||||
.dropdown-item:hover:not(.disabled, .dropdown-container-item),
|
||||
.tabulator-menu-item:hover {
|
||||
color: var(--hover-item-text-color) !important;
|
||||
background-color: var(--hover-item-background-color) !important;
|
||||
@@ -389,9 +423,9 @@ body.desktop .tabulator-popup-container {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item-container,
|
||||
.dropdown-item-container:hover,
|
||||
.dropdown-item-container:active {
|
||||
.dropdown-container-item,
|
||||
.dropdown-item.dropdown-container-item:hover,
|
||||
.dropdown-container-item:active {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -406,14 +440,20 @@ body #context-menu-container .dropdown-item > span {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-menu kbd {
|
||||
.dropdown-item span.keyboard-shortcut,
|
||||
.dropdown-item *:not(.keyboard-shortcut) > kbd {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
.dropdown-menu kbd {
|
||||
color: var(--muted-text-color);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
padding-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dropdown-item,
|
||||
@@ -422,6 +462,12 @@ body #context-menu-container .dropdown-item > span {
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
/* This is a workaround for Firefox not supporting break-before / break-after: avoid on columns.
|
||||
* It usually wraps a menu item followed by a separator / header and another menu item. */
|
||||
.dropdown-no-break {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.dropdown-item.disabled,
|
||||
.dropdown-item.disabled kbd {
|
||||
color: #aaa !important;
|
||||
@@ -429,9 +475,9 @@ body #context-menu-container .dropdown-item > span {
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:focus {
|
||||
color: var(--active-item-text-color) !important;
|
||||
background-color: var(--active-item-background-color) !important;
|
||||
border-color: var(--active-item-border-color) !important;
|
||||
color: var(--active-item-text-color);
|
||||
background-color: var(--active-item-background-color);
|
||||
border-color: var(--active-item-border-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -831,10 +877,34 @@ table.promoted-attributes-in-tooltip th {
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
padding: 6px 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .icon {
|
||||
display: inline-block;
|
||||
line-height: inherit;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .text {
|
||||
display: inline-block;
|
||||
width: calc(100% - 20px);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .search-result-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .search-result-attributes {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion p {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -922,6 +992,11 @@ div[data-notify="container"] {
|
||||
font-family: var(--monospace-font-family);
|
||||
}
|
||||
|
||||
svg.ck-icon .note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
--ck-content-font-family: var(--detail-font-family);
|
||||
--ck-content-font-size: 1.1em;
|
||||
@@ -1063,6 +1138,27 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
|
||||
.toast-body {
|
||||
white-space: preserve-breaks;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast.no-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
|
||||
}
|
||||
|
||||
.toast.no-title .toast-body {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-header {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.ck-mentions .ck-button {
|
||||
@@ -1171,6 +1267,10 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.hidden-ext.note-split + .gutter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#context-menu-cover.show {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1392,7 +1492,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
color: var(--launcher-pane-text-color);
|
||||
background-color: var(--launcher-pane-background-color);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1700,7 +1800,6 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
}
|
||||
|
||||
.note-split {
|
||||
flex-basis: 0; /* so that each split has same width */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -1738,16 +1837,12 @@ button.close:hover {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.options-number-input {
|
||||
.options-section input[type="number"] {
|
||||
/* overriding settings from .form-control */
|
||||
width: 10em !important;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
|
||||
.options-mime-types {
|
||||
column-width: 250px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
cursor: auto;
|
||||
}
|
||||
@@ -1768,20 +1863,37 @@ textarea {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-dialog {
|
||||
max-width: 900px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-dropdown-menu {
|
||||
max-height: 40vh;
|
||||
max-height: calc(80vh - 200px);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.jump-to-note-results {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestions {
|
||||
padding: 1rem;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
@@ -1799,8 +1911,24 @@ textarea {
|
||||
|
||||
.jump-to-note-dialog .aa-cursor .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion:hover .command-suggestion {
|
||||
border-left-color: var(--link-color);
|
||||
background-color: var(--hover-background-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .show-in-full-search,
|
||||
.jump-to-note-results .show-in-full-search {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestion .search-notes-action {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestion:has(.search-notes-action)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-icon {
|
||||
@@ -1897,12 +2025,16 @@ body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .note-icon-widget,
|
||||
body.zen .title-row .button-widget,
|
||||
body.zen .title-row .icon-action,
|
||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||
body.zen .action-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.zen .split-note-container-widget > .gutter {
|
||||
display: unset !important;
|
||||
}
|
||||
|
||||
body.zen #launcher-pane {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
@@ -2255,16 +2387,27 @@ footer.webview-footer button {
|
||||
padding: 1px 10px 1px 10px;
|
||||
}
|
||||
|
||||
/* Search result highlighting */
|
||||
.search-result-title b,
|
||||
.search-result-content b {
|
||||
font-weight: 900;
|
||||
color: var(--admonition-warning-accent-color);
|
||||
}
|
||||
|
||||
/* Customized icons */
|
||||
|
||||
.bx-tn-toc::before {
|
||||
content: "\ec24";
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* CK Edito */
|
||||
|
||||
/* Insert text snippet: limit the width of the listed items to avoid overly long names */
|
||||
:root body.desktop div.ck-template-form li.ck-list__item .ck-template-form__text-part > span {
|
||||
max-width: 25vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.revision-diff-added {
|
||||
background: rgba(100, 200, 100, 0.5);
|
||||
}
|
||||
|
||||
.revision-diff-removed {
|
||||
background: rgba(255, 100, 100, 0.5);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
:root {
|
||||
--theme-style: dark;
|
||||
|
||||
--main-font-family: Montserrat;
|
||||
--main-font-family: Montserrat, sans-serif;
|
||||
--main-font-size: normal;
|
||||
|
||||
--tree-font-family: Montserrat;
|
||||
--tree-font-family: Montserrat, sans-serif;
|
||||
--tree-font-size: normal;
|
||||
|
||||
--detail-font-family: Montserrat;
|
||||
--detail-font-family: Montserrat, sans-serif;
|
||||
--detail-font-size: normal;
|
||||
|
||||
--monospace-font-family: JetBrainsLight;
|
||||
--monospace-font-family: JetBrainsLight, monospace;
|
||||
--monospace-font-size: normal;
|
||||
|
||||
--main-background-color: #333;
|
||||
|
||||
@@ -5,16 +5,16 @@ html {
|
||||
/* either light or dark, colored theme with darker tones are also dark, used e.g. for note map node colors */
|
||||
--theme-style: light;
|
||||
|
||||
--main-font-family: Montserrat;
|
||||
--main-font-family: Montserrat, sans-serif;
|
||||
--main-font-size: normal;
|
||||
|
||||
--tree-font-family: Montserrat;
|
||||
--tree-font-family: Montserrat, sans-serif;
|
||||
--tree-font-size: normal;
|
||||
|
||||
--detail-font-family: Montserrat;
|
||||
--detail-font-family: Montserrat, sans-serif;
|
||||
--detail-font-size: normal;
|
||||
|
||||
--monospace-font-family: JetBrainsLight;
|
||||
--monospace-font-family: JetBrainsLight, monospace;
|
||||
--monospace-font-size: normal;
|
||||
|
||||
--main-background-color: white;
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
|
||||
--theme-style: dark;
|
||||
--native-titlebar-background: #00000000;
|
||||
--window-background-color-bgfx: transparent; /* When background effects enabled */
|
||||
|
||||
--main-background-color: #272727;
|
||||
--main-text-color: #ccc;
|
||||
--main-border-color: #454545;
|
||||
--subtle-border-color: #313131;
|
||||
--dropdown-border-color: #292929;
|
||||
--dropdown-border-color: #404040;
|
||||
--dropdown-shadow-opacity: 0.6;
|
||||
--dropdown-item-icon-destructive-color: #de6e5b;
|
||||
--disabled-tooltip-icon-color: #7fd2ef;
|
||||
@@ -89,6 +90,7 @@
|
||||
|
||||
--menu-text-color: #e3e3e3;
|
||||
--menu-background-color: #222222d9;
|
||||
--menu-background-color-no-backdrop: #1b1b1b;
|
||||
--menu-item-icon-color: #8c8c8c;
|
||||
--menu-item-disabled-opacity: 0.5;
|
||||
--menu-item-keyboard-shortcut-color: #ffffff8f;
|
||||
@@ -120,6 +122,8 @@
|
||||
--quick-search-focus-border: #80808095;
|
||||
--quick-search-focus-background: #ffffff1f;
|
||||
--quick-search-focus-color: white;
|
||||
--quick-search-result-content-background: #0000004d;
|
||||
--quick-search-result-highlight-color: #a4d995;
|
||||
|
||||
--left-pane-collapsed-border-color: #0009;
|
||||
--left-pane-background-color: #1f1f1f;
|
||||
@@ -144,14 +148,17 @@
|
||||
--launcher-pane-vert-button-hover-background: #ffffff1c;
|
||||
--launcher-pane-vert-button-hover-shadow: 4px 4px 4px rgba(0, 0, 0, 0.2);
|
||||
--launcher-pane-vert-button-focus-outline-color: var(--input-focus-outline-color);
|
||||
--launcher-pane-vert-background-color-bgfx: #00000026; /* When background effects enabled */
|
||||
|
||||
--launcher-pane-horiz-border-color: rgb(22, 22, 22);
|
||||
--launcher-pane-horiz-background-color: #282828;
|
||||
--launcher-pane-horiz-text-color: #909090;
|
||||
--launcher-pane-horiz-text-color: #b8b8b8;
|
||||
--launcher-pane-horiz-button-hover-color: #ffffff;
|
||||
--launcher-pane-horiz-button-hover-background: #ffffff1c;
|
||||
--launcher-pane-horiz-button-hover-shadow: unset;
|
||||
--launcher-pane-horiz-button-focus-outline-color: var(--input-focus-outline-color);
|
||||
--launcher-pane-horiz-background-color-bgfx: #ffffff17; /* When background effects enabled */
|
||||
--launcher-pane-horiz-border-color-bgfx: #00000080; /* When background effects enabled */
|
||||
|
||||
--protected-session-active-icon-color: #8edd8e;
|
||||
--sync-status-error-pulse-color: #f47871;
|
||||
@@ -165,9 +172,10 @@
|
||||
|
||||
--tab-close-button-hover-background: #a45353;
|
||||
--tab-close-button-hover-color: white;
|
||||
|
||||
|
||||
--active-tab-background-color: #ffffff1c;
|
||||
--active-tab-hover-background-color: var(--active-tab-background-color);
|
||||
--active-tab-icon-color: #a9a9a9;
|
||||
--active-tab-text-color: #ffffffcd;
|
||||
--active-tab-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2), -1px -1px 3px rgba(0, 0, 0, 0.4);
|
||||
--active-tab-dragging-shadow: var(--active-tab-shadow), 0 0 20px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
--theme-style: light;
|
||||
--native-titlebar-background: #ffffff00;
|
||||
--window-background-color-bgfx: transparent; /* When background effects enabled */
|
||||
|
||||
--main-background-color: white;
|
||||
--main-text-color: black;
|
||||
@@ -83,6 +84,7 @@
|
||||
|
||||
--menu-text-color: #272727;
|
||||
--menu-background-color: #ffffffd9;
|
||||
--menu-background-color-no-backdrop: #fdfdfd;
|
||||
--menu-item-icon-color: #727272;
|
||||
--menu-item-disabled-opacity: 0.6;
|
||||
--menu-item-keyboard-shortcut-color: #666666a8;
|
||||
@@ -114,15 +116,17 @@
|
||||
--quick-search-focus-border: #00000029;
|
||||
--quick-search-focus-background: #ffffff80;
|
||||
--quick-search-focus-color: #000;
|
||||
--quick-search-result-content-background: #0000000f;
|
||||
--quick-search-result-highlight-color: #c65050;
|
||||
|
||||
--left-pane-collapsed-border-color: #0000000d;
|
||||
--left-pane-background-color: #f2f2f2;
|
||||
--left-pane-text-color: #383838;
|
||||
--left-pane-item-hover-background: #eaeaea;
|
||||
--left-pane-item-hover-background: rgba(0, 0, 0, 0.032);
|
||||
--left-pane-item-selected-background: white;
|
||||
--left-pane-item-selected-color: black;
|
||||
--left-pane-item-selected-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--left-pane-item-action-button-background: #d7d7d7;
|
||||
--left-pane-item-action-button-background: rgba(0, 0, 0, 0.11);
|
||||
--left-pane-item-action-button-color: inherit;
|
||||
--left-pane-item-action-button-hover-background: white;
|
||||
--left-pane-item-action-button-hover-shadow: 2px 2px 3px rgba(0, 0, 0, 0.15);
|
||||
@@ -138,6 +142,7 @@
|
||||
--launcher-pane-vert-button-hover-background: white;
|
||||
--launcher-pane-vert-button-hover-shadow: 4px 4px 4px rgba(0, 0, 0, 0.075);
|
||||
--launcher-pane-vert-button-focus-outline-color: var(--input-focus-outline-color);
|
||||
--launcher-pane-vert-background-color-bgfx: #00000009; /* When background effects enabled */
|
||||
|
||||
--launcher-pane-horiz-border-color: rgba(0, 0, 0, 0.1);
|
||||
--launcher-pane-horiz-background-color: #fafafa;
|
||||
@@ -145,6 +150,8 @@
|
||||
--launcher-pane-horiz-button-hover-background: var(--icon-button-hover-background);
|
||||
--launcher-pane-horiz-button-hover-shadow: unset;
|
||||
--launcher-pane-horiz-button-focus-outline-color: var(--input-focus-outline-color);
|
||||
--launcher-pane-horiz-background-color-bgfx: #ffffffb3; /* When background effects enabled */
|
||||
--launcher-pane-horiz-border-color-bgfx: #00000026; /* When background effects enabled */
|
||||
|
||||
--protected-session-active-icon-color: #16b516;
|
||||
--sync-status-error-pulse-color: #ff5528;
|
||||
@@ -158,9 +165,10 @@
|
||||
|
||||
--tab-close-button-hover-background: #c95a5a;
|
||||
--tab-close-button-hover-color: white;
|
||||
|
||||
|
||||
--active-tab-background-color: white;
|
||||
--active-tab-hover-background-color: var(--active-tab-background-color);
|
||||
--active-tab-icon-color: gray;
|
||||
--active-tab-text-color: black;
|
||||
--active-tab-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -1px -1px 3px rgba(0, 0, 0, 0.05);
|
||||
--active-tab-dragging-shadow: var(--active-tab-shadow), 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
--detail-font-family: var(--main-font-family);
|
||||
--detail-font-size: normal;
|
||||
|
||||
--monospace-font-family: JetBrainsLight;
|
||||
--monospace-font-family: JetBrainsLight, monospace;
|
||||
--monospace-font-size: normal;
|
||||
|
||||
--left-pane-item-selected-shadow-size: 2px;
|
||||
@@ -83,6 +83,12 @@
|
||||
--tab-note-icons: true;
|
||||
}
|
||||
|
||||
body.backdrop-effects-disabled {
|
||||
/* Backdrop effects are disabled, replace the menu background color with the
|
||||
* no-backdrop fallback color */
|
||||
--menu-background-color: var(--menu-background-color-no-backdrop);
|
||||
}
|
||||
|
||||
/*
|
||||
* MENUS
|
||||
*
|
||||
@@ -96,10 +102,6 @@
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
--scrollbar-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
body.mobile .dropdown-menu {
|
||||
backdrop-filter: var(--dropdown-backdrop-filter);
|
||||
border-radius: var(--dropdown-border-radius);
|
||||
@@ -148,12 +150,22 @@ body.desktop .dropdown-submenu .dropdown-menu {
|
||||
.dropdown-item,
|
||||
body.mobile .dropdown-submenu .dropdown-toggle {
|
||||
padding: 2px 2px 2px 8px !important;
|
||||
padding-inline-end: 16px !important;
|
||||
padding-inline-end: 22px !important;
|
||||
/* Note: the right padding should also accommodate the submenu arrow. */
|
||||
border-radius: 6px;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
:root .dropdown-item:focus-visible {
|
||||
outline: 2px solid var(--input-focus-outline-color) !important;
|
||||
background-color: transparent;
|
||||
color: unset;
|
||||
}
|
||||
|
||||
:root .dropdown-item:active {
|
||||
background: unset;
|
||||
}
|
||||
|
||||
body.mobile .dropdown-submenu {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -191,13 +203,17 @@ html body .dropdown-item[disabled] {
|
||||
|
||||
/* Menu item keyboard shortcut */
|
||||
.dropdown-item kbd {
|
||||
margin-left: 16px;
|
||||
font-family: unset !important;
|
||||
font-size: unset !important;
|
||||
color: var(--menu-item-keyboard-shortcut-color) !important;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.dropdown-item span.keyboard-shortcut {
|
||||
color: var(--menu-item-keyboard-shortcut-color) !important;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
position: relative;
|
||||
border-color: transparent !important;
|
||||
@@ -279,6 +295,20 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
/* Dropdown item button (used in zoom buttons in global menu) */
|
||||
|
||||
li.dropdown-item a.dropdown-item-button {
|
||||
border: unset;
|
||||
}
|
||||
|
||||
li.dropdown-item a.dropdown-item-button.bx {
|
||||
color: var(--menu-text-color) !important;
|
||||
}
|
||||
|
||||
li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
outline: 2px solid var(--input-focus-outline-color) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* TOASTS
|
||||
*/
|
||||
@@ -297,28 +327,49 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
--modal-control-button-color: var(--bs-toast-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-header {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border-bottom: none;
|
||||
color: var(--toast-text-color) !important;
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-header strong {
|
||||
/* The title of the toast is no longer displayed */
|
||||
display: none;
|
||||
#toast-container .toast .toast-header strong > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-header .btn-close {
|
||||
margin: 0 var(--bs-toast-padding-x) 0 12px;
|
||||
margin: 0 0 0 12px;
|
||||
}
|
||||
|
||||
#toast-container .toast.no-title {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-body {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#toast-container .toast:not(.no-title) .bx {
|
||||
margin-right: 0.5em;
|
||||
font-size: 1.1em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
#toast-container .toast.no-title .bx {
|
||||
margin-right: 0;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
#toast-container .toast.no-title .toast-body {
|
||||
padding-top: var(--bs-toast-padding-x);
|
||||
color: var(--toast-text-color);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -530,10 +581,9 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
}
|
||||
|
||||
/* List item */
|
||||
.jump-to-note-dialog .aa-suggestions div,
|
||||
.note-detail-empty .aa-suggestions div {
|
||||
.jump-to-note-dialog .aa-suggestion,
|
||||
.note-detail-empty .aa-suggestion {
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: var(--menu-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user