mirror of
https://github.com/zadam/trilium.git
synced 2025-10-27 16:26:31 +01:00
Compare commits
759 Commits
v0.97.0
...
feat/bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5024e27885 | ||
|
|
78c27dbe04 | ||
|
|
384a89b0e3 | ||
|
|
e7fd9371b6 | ||
|
|
aa83429816 | ||
|
|
221ab02c24 | ||
|
|
0c4b751e8f | ||
|
|
43fd0924a1 | ||
|
|
7a036fc777 | ||
|
|
54efa6b38c | ||
|
|
6e37c9ee5a | ||
|
|
963f4586f3 | ||
|
|
4d0edebed3 | ||
|
|
cb39e8d0f8 | ||
|
|
a336f472b8 | ||
|
|
0a097e72be | ||
|
|
077f10af7b | ||
|
|
9317658fc7 | ||
|
|
21a13f2124 | ||
|
|
db6658c05f | ||
|
|
653af0bc06 | ||
|
|
93c5281af7 | ||
|
|
ce28fbc968 | ||
|
|
eb41c45711 | ||
|
|
17ab14e098 | ||
|
|
a42f7b4ece | ||
|
|
c7d69fa66b | ||
|
|
1da5c083ee | ||
|
|
4fb911da40 | ||
|
|
881417f860 | ||
|
|
9748b8bf94 | ||
|
|
337326da2b | ||
|
|
a088134d9b | ||
|
|
e49100f3f4 | ||
|
|
3c638e1574 | ||
|
|
9131edf021 | ||
|
|
52a1318475 | ||
|
|
5a7483d7c7 | ||
|
|
41f2748829 | ||
|
|
66bd5268ca | ||
|
|
ebef134af7 | ||
|
|
1173bf22ab | ||
|
|
e8f6828168 | ||
|
|
02c9339f9c | ||
|
|
c72bf42684 | ||
|
|
f42eeb7ee8 | ||
|
|
3d876121cc | ||
|
|
f9bcd7d90a | ||
|
|
b3af14fccb | ||
|
|
d224f33913 | ||
|
|
3a5f33ba91 | ||
|
|
e1ae8701b2 | ||
|
|
aff5a4d0d5 | ||
|
|
17467a9c29 | ||
|
|
ebed661863 | ||
|
|
c2e9f4764b | ||
|
|
7e5b87f00a | ||
|
|
70182e863c | ||
|
|
f0d30c4e34 | ||
|
|
013e7a6aa4 | ||
|
|
1b25b18d9e | ||
|
|
72ff384187 | ||
|
|
bac048f60f | ||
|
|
d8d0a64134 | ||
|
|
b2db87db4e | ||
|
|
1baaee582e | ||
|
|
9212b72351 | ||
|
|
24af820477 | ||
|
|
6c4c2d22c6 | ||
|
|
2e9b20be71 | ||
|
|
3216de5d89 | ||
|
|
4bf7cb8099 | ||
|
|
4871dbd7ef | ||
|
|
e125809fe0 | ||
|
|
27b80b573f | ||
|
|
38d6ae87b6 | ||
|
|
1a7cbc13e0 | ||
|
|
8e4691d4a4 | ||
|
|
b371337ed2 | ||
|
|
4d4b76ce39 | ||
|
|
289d3e9882 | ||
|
|
a57eb8f27f | ||
|
|
27023f1fd5 | ||
|
|
20a152993f | ||
|
|
ba7636db75 | ||
|
|
e3e51a2e1f | ||
|
|
93b601fe98 | ||
|
|
203ebb0e7a | ||
|
|
041c2e5693 | ||
|
|
258c0d511e | ||
|
|
15705553c7 | ||
|
|
27f023e399 | ||
|
|
0d2242171c | ||
|
|
0c62ecda65 | ||
|
|
7cd7fec93b | ||
|
|
cfab5e6217 | ||
|
|
0c313e8b8f | ||
|
|
61366061e6 | ||
|
|
b2c0685c09 | ||
|
|
e3ee284e91 | ||
|
|
58901855af | ||
|
|
c7f8a49c47 | ||
|
|
10685fe183 | ||
|
|
9e514fe95e | ||
|
|
f28a319e26 | ||
|
|
decfb58142 | ||
|
|
415bbc3b0a | ||
|
|
5b669ca287 | ||
|
|
1700217241 | ||
|
|
f00c0d5d73 | ||
|
|
64ce0d2911 | ||
|
|
82dce7a0d3 | ||
|
|
b94f67aa72 | ||
|
|
1ff77a1464 | ||
|
|
adb0e1e844 | ||
|
|
2043a06a48 | ||
|
|
738ebb66ac | ||
|
|
abf1f6c041 | ||
|
|
7db0e90506 | ||
|
|
400f9cf911 | ||
|
|
2d3b99c959 | ||
|
|
fd1ea05c78 | ||
|
|
d7c4b8f530 | ||
|
|
238f358d6a | ||
|
|
50afdca150 | ||
|
|
b5555d94f5 | ||
|
|
3d81633214 | ||
|
|
5db7997a17 | ||
|
|
71dd428919 | ||
|
|
a20d66a6b5 | ||
|
|
3caefa5409 | ||
|
|
a6e56be55a | ||
|
|
6d582f09be | ||
|
|
40cd46cd09 | ||
|
|
3cc59149cf | ||
|
|
e659266d62 | ||
|
|
14e09f5ea0 | ||
|
|
11f6462a31 | ||
|
|
48eebbe2fe | ||
|
|
f7093c035b | ||
|
|
b25e9cdee6 | ||
|
|
5cd7e4707a | ||
|
|
861374bb87 | ||
|
|
d3519b3059 | ||
|
|
da1f18c60f | ||
|
|
b7482f2a6a | ||
|
|
fd616cafca | ||
|
|
b262a5181f | ||
|
|
adb54a9054 | ||
|
|
5eb05f5550 | ||
|
|
2950c5eaa4 | ||
|
|
16fd67c070 | ||
|
|
9e3559f97c | ||
|
|
83eea30ea0 | ||
|
|
6ceccf1c7a | ||
|
|
31e1c4c712 | ||
|
|
fa97ec6c72 | ||
|
|
cd5467bf5c | ||
|
|
899f85f4e7 | ||
|
|
7c79fbefa6 | ||
|
|
18c6fe7ebd | ||
|
|
6f6643d758 | ||
|
|
356adebbce | ||
|
|
5c8e4fd6fd | ||
|
|
5be9bb47a7 | ||
|
|
60c5dc525b | ||
|
|
abfffcec07 | ||
|
|
09b12052f0 | ||
|
|
78bb0ab016 | ||
|
|
4cd4c2f607 | ||
|
|
f95b5d6f14 | ||
|
|
4a53be1e33 | ||
|
|
cbbe845d7b | ||
|
|
b2b52e92a4 | ||
|
|
15a97a4675 | ||
|
|
a01f25ec12 | ||
|
|
2f175765ec | ||
|
|
6a7ae72b1b | ||
|
|
e396bb1641 | ||
|
|
baedac4746 | ||
|
|
268ef626ca | ||
|
|
40c7ad4b46 | ||
|
|
54f9ce87f9 | ||
|
|
12b8a70e5c | ||
|
|
acf204d0e3 | ||
|
|
ee19f9ccaa | ||
|
|
34c0cf33b9 | ||
|
|
34ec624e46 | ||
|
|
056d3f9f36 | ||
|
|
040673af0b | ||
|
|
48fb0c5e21 | ||
|
|
4c1a55708f | ||
|
|
6e1951b356 | ||
|
|
3dd6b05d2e | ||
|
|
05f1ae01f3 | ||
|
|
3975041798 | ||
|
|
3a29d65777 | ||
|
|
eeeecb3988 | ||
|
|
28ababcbb9 | ||
|
|
f382943af3 | ||
|
|
fa38332a6c | ||
|
|
5a58fcde96 | ||
|
|
62d048433b | ||
|
|
db4ba53449 | ||
|
|
da20916767 | ||
|
|
b1e12182ce | ||
|
|
80b2061935 | ||
|
|
8ce92f8c93 | ||
|
|
05cd8cb547 | ||
|
|
6e7d0bc51b | ||
|
|
b9aede23e6 | ||
|
|
1d52988826 | ||
|
|
ebe29f41f9 | ||
|
|
598591a2da | ||
|
|
32c2860b68 | ||
|
|
d975790e79 | ||
|
|
3e1f74ae93 | ||
|
|
81a8908b98 | ||
|
|
892dfe2340 | ||
|
|
fd175eb8a8 | ||
|
|
c98f6d96d5 | ||
|
|
35b628e799 | ||
|
|
49b79c016d | ||
|
|
4d28df7a89 | ||
|
|
25a9a8a724 | ||
|
|
313a61ec48 | ||
|
|
a2eab03ee2 | ||
|
|
a563b1c9a0 | ||
|
|
20018b9c21 | ||
|
|
0a9bd5f6d1 | ||
|
|
911fee0213 | ||
|
|
ffe4b53eee | ||
|
|
cd5a68d230 | ||
|
|
95a2a69e0a | ||
|
|
360b5d6de4 | ||
|
|
bf50883e40 | ||
|
|
8e04690568 | ||
|
|
bd6c690160 | ||
|
|
c0d7278827 | ||
|
|
f9eb0a20f7 | ||
|
|
8d27a5aa39 | ||
|
|
90f9416524 | ||
|
|
ae0af8b9c7 | ||
|
|
a03a0f8a75 | ||
|
|
f0f27a9065 | ||
|
|
181d5ee36a | ||
|
|
2758a230ac | ||
|
|
a46d32ed75 | ||
|
|
b2bcae8b74 | ||
|
|
49d662afba | ||
|
|
a593ce7c40 | ||
|
|
31fbf2cb57 | ||
|
|
c0d3027e65 | ||
|
|
bde270b73f | ||
|
|
edd18b53d0 | ||
|
|
2ad4b26c9e | ||
|
|
f39a5c55ba | ||
|
|
0af5feab79 | ||
|
|
68dd54a100 | ||
|
|
7a0f148d28 | ||
|
|
958b1592f8 | ||
|
|
7ac0828ae7 | ||
|
|
f7e7b38551 | ||
|
|
33e3112290 | ||
|
|
2a27666c53 | ||
|
|
f2d45cb780 | ||
|
|
c4b91c9777 | ||
|
|
fa06f56f5d | ||
|
|
519b962af3 | ||
|
|
31e6ac2349 | ||
|
|
ed3ba2745f | ||
|
|
f5b7648d6d | ||
|
|
2d537b82f6 | ||
|
|
073354fe04 | ||
|
|
165d093928 | ||
|
|
e8cf3f4a10 | ||
|
|
2a40d6bb7e | ||
|
|
f196a78728 | ||
|
|
523c7ac273 | ||
|
|
c36b00994b | ||
|
|
76b856bfe5 | ||
|
|
7b084035a3 | ||
|
|
59fbdaa879 | ||
|
|
ce324586f8 | ||
|
|
35bd210062 | ||
|
|
0cfe3351bb | ||
|
|
7202f47716 | ||
|
|
bde4545afc | ||
|
|
b3c81ce5f2 | ||
|
|
02b0d1fb5e | ||
|
|
87d9ea06f3 | ||
|
|
a4e6a964c9 | ||
|
|
79c5d479fc | ||
|
|
8f0a9f91c1 | ||
|
|
93fae9cc8c | ||
|
|
1046321117 | ||
|
|
00fc92764b | ||
|
|
dea8bc307e | ||
|
|
18a4fbaa4b | ||
|
|
3efc4b13d5 | ||
|
|
952456a69c | ||
|
|
bde8e17fe6 | ||
|
|
9023ba1d0a | ||
|
|
61f9a86685 | ||
|
|
5520cfed5d | ||
|
|
43df984732 | ||
|
|
3f398c1a00 | ||
|
|
ad35e3b48f | ||
|
|
73ee44e177 | ||
|
|
18414cd155 | ||
|
|
652d78ac68 | ||
|
|
9a3ab05d73 | ||
|
|
fe238b8afd | ||
|
|
94492c7535 | ||
|
|
47caf970a1 | ||
|
|
3e75ab39c2 | ||
|
|
72aacdbf6f | ||
|
|
5461dafe02 | ||
|
|
30f9f66b8b | ||
|
|
19de803142 | ||
|
|
11b247fe07 | ||
|
|
faa40494d8 | ||
|
|
796802aea0 | ||
|
|
06af5cf6d5 | ||
|
|
81a99c1e44 | ||
|
|
1b384f35d2 | ||
|
|
c1259f2ea2 | ||
|
|
92d9c82d97 | ||
|
|
064f0ef921 | ||
|
|
e9a9b462d4 | ||
|
|
98888d5f1d | ||
|
|
134c869b07 | ||
|
|
beb0487513 | ||
|
|
aa9ffb8f6b | ||
|
|
18eb704b81 | ||
|
|
83fb62d4df | ||
|
|
cb650b70cb | ||
|
|
d5e42318dd | ||
|
|
24ed474c8c | ||
|
|
a9c25b4edd | ||
|
|
c89737ae7b | ||
|
|
e619a6ef7c | ||
|
|
6a2a096348 | ||
|
|
bf34ef2009 | ||
|
|
583ab8dc92 | ||
|
|
db1619af31 | ||
|
|
9cddb9ac1d | ||
|
|
d72d3db2a0 | ||
|
|
22740a6c8d | ||
|
|
e9409577db | ||
|
|
9cef8c8e70 | ||
|
|
53bcec602d | ||
|
|
a62f12b427 | ||
|
|
e20816a7ce | ||
|
|
58535df676 | ||
|
|
057040af06 | ||
|
|
c603783a44 | ||
|
|
1928356ad5 | ||
|
|
e53ad2c62a | ||
|
|
bca397e3e4 | ||
|
|
14b3bea203 | ||
|
|
05c26d17d3 | ||
|
|
51360d855a | ||
|
|
ae7d03e3c7 | ||
|
|
164feaa3ec | ||
|
|
4d09fabad8 | ||
|
|
87e1ce64d1 | ||
|
|
04913394c6 | ||
|
|
f8b563704f | ||
|
|
5d9bd0f6d3 | ||
|
|
1229c26098 | ||
|
|
77818d5453 | ||
|
|
f9c7c5637b | ||
|
|
5d55b0b0a8 | ||
|
|
b2d7fbbcad | ||
|
|
fbc6734e08 | ||
|
|
a83172390f | ||
|
|
4b1fd5e4a0 | ||
|
|
51495b282f | ||
|
|
b645d21fcd | ||
|
|
8f99ce7d14 | ||
|
|
6eb650bb22 | ||
|
|
a7f5702221 | ||
|
|
efeb9b90ca | ||
|
|
3361a2e4ab | ||
|
|
425ade5212 | ||
|
|
384ab1d1f3 | ||
|
|
70b1a37285 | ||
|
|
61a878e2a0 | ||
|
|
319cb8384c | ||
|
|
2d358342c5 | ||
|
|
dd7ee05388 | ||
|
|
6c79be881d | ||
|
|
51a8937c64 | ||
|
|
c436455b32 | ||
|
|
f740edae91 | ||
|
|
18f89b979d | ||
|
|
8094259c78 | ||
|
|
b4f503b81e | ||
|
|
4db04519bd | ||
|
|
464c2bdf28 | ||
|
|
8007bac8b8 | ||
|
|
7a1ec266ad | ||
|
|
42fedaa241 | ||
|
|
4387bd4c6f | ||
|
|
51e1367b82 | ||
|
|
8bea3f4422 | ||
|
|
0eb2e405ff | ||
|
|
5dbd4a765f | ||
|
|
f6961c7e06 | ||
|
|
8d3ba90072 | ||
|
|
3772412d82 | ||
|
|
84389f467e | ||
|
|
eb41e0f96f | ||
|
|
2d44dff997 | ||
|
|
1483bf3d46 | ||
|
|
064cf6a3ee | ||
|
|
0c0d5eaa0a | ||
|
|
afecb33b5c | ||
|
|
fbb1e3a302 | ||
|
|
8704350359 | ||
|
|
d09e725d98 | ||
|
|
8be5b149c4 | ||
|
|
faeea6af18 | ||
|
|
3fa5ea1010 | ||
|
|
6aa31ae125 | ||
|
|
27f2e9c286 | ||
|
|
67cc36fdd2 | ||
|
|
ef7297e03b | ||
|
|
97a5314cdb | ||
|
|
a1195a2856 | ||
|
|
81419c6fe3 | ||
|
|
b8da793353 | ||
|
|
8140fa79cc | ||
|
|
abff4fe67d | ||
|
|
ec8f737eba | ||
|
|
cc6688ea00 | ||
|
|
c448b29be7 | ||
|
|
61bde294b3 | ||
|
|
acab81c61e | ||
|
|
1dd965973b | ||
|
|
d61981033f | ||
|
|
30197ba7ce | ||
|
|
1b6c957334 | ||
|
|
fb7a397bf9 | ||
|
|
133c9c5a7b | ||
|
|
8a587d4d21 | ||
|
|
29b813fa3b | ||
|
|
1dfe27d3df | ||
|
|
cda8fc7146 | ||
|
|
acb16f751b | ||
|
|
a1ac276be5 | ||
|
|
54e3ab5139 | ||
|
|
baf341b312 | ||
|
|
5b074c2e22 | ||
|
|
11d086ef12 | ||
|
|
0e6b10e400 | ||
|
|
0240222998 | ||
|
|
7fc739487f | ||
|
|
f6e275709f | ||
|
|
7e01dfd220 | ||
|
|
d5866a99ec | ||
|
|
5289d41b12 | ||
|
|
030178cad2 | ||
|
|
5d00630452 | ||
|
|
eb805bfa2a | ||
|
|
ee3a8e105e | ||
|
|
97fb273e7f | ||
|
|
2ef9009384 | ||
|
|
27c7888628 | ||
|
|
b4de37a9f4 | ||
|
|
1c5ebb54f8 | ||
|
|
f3e69dd6bd | ||
|
|
66364f5ce0 | ||
|
|
f25a1fb865 | ||
|
|
62c5b8b1fc | ||
|
|
2b0de37fc0 | ||
|
|
23ef73fe2f | ||
|
|
92ac3ee4ef | ||
|
|
a3ba5ca109 | ||
|
|
5b4e81cf18 | ||
|
|
772e6f5ebc | ||
|
|
60a9428b8b | ||
|
|
a7752a8421 | ||
|
|
aefa2315b7 | ||
|
|
37a79aeeab | ||
|
|
5bc4bdaeef | ||
|
|
5e28df883d | ||
|
|
0a57748075 | ||
|
|
45e3eee642 | ||
|
|
d724a80c2a | ||
|
|
5ea8c94d18 | ||
|
|
769bc760b3 | ||
|
|
f04f45ea62 | ||
|
|
a5cab6a2a2 | ||
|
|
138611beaf | ||
|
|
e1b608057a | ||
|
|
fed6d8329f | ||
|
|
9d03d52f28 | ||
|
|
055e11174d | ||
|
|
8fda2dd7f1 | ||
|
|
ea03695c75 | ||
|
|
17b206fc72 | ||
|
|
4ec8c5963a | ||
|
|
ab2d8accf5 | ||
|
|
de8b7e9ebe | ||
|
|
18d11523a6 | ||
|
|
7a0ab3c025 | ||
|
|
3575a7dc93 | ||
|
|
bb9e7b1c6e | ||
|
|
115e9e0202 | ||
|
|
e341de70c0 | ||
|
|
1d1a0ac4fd | ||
|
|
d48470ffb1 | ||
|
|
6574ca42a3 | ||
|
|
303ff35a76 | ||
|
|
e0850958b0 | ||
|
|
13115b9ed1 | ||
|
|
933a11e9db | ||
|
|
6915993a35 | ||
|
|
237a4e9a74 | ||
|
|
1565a0fd80 | ||
|
|
e8b16287e0 | ||
|
|
c09e124805 | ||
|
|
b6f55b0e1a | ||
|
|
964bc74b83 | ||
|
|
fa9b142cb7 | ||
|
|
7e3f412c84 | ||
|
|
82e16a5624 | ||
|
|
757488a95b | ||
|
|
d7f154cfd1 | ||
|
|
3517715aab | ||
|
|
d10bbdd7a7 | ||
|
|
c4ec27bb1e | ||
|
|
0b24553ace | ||
|
|
793867269b | ||
|
|
9508e92676 | ||
|
|
89378eae7b | ||
|
|
ace166a925 | ||
|
|
d59d544c0f | ||
|
|
37461d0eb3 | ||
|
|
126152ff63 | ||
|
|
60e19de0d1 | ||
|
|
3247a9facc | ||
|
|
7b114bed26 | ||
|
|
30ffbc760e | ||
|
|
4420913049 | ||
|
|
3762690c5f | ||
|
|
d684ac40d8 | ||
|
|
d217379644 | ||
|
|
d5f7fa2fe5 | ||
|
|
3e0ef10b25 | ||
|
|
28f88f2407 | ||
|
|
e525a7a0ff | ||
|
|
3415f38e0a | ||
|
|
910c0faade | ||
|
|
4ad1bb5e3a | ||
|
|
97f6f0a945 | ||
|
|
bc78c17a11 | ||
|
|
b8e813f7bd | ||
|
|
db3581eb26 | ||
|
|
d23230df68 | ||
|
|
b29781b614 | ||
|
|
7d7c3e7cdb | ||
|
|
cbd8cb80ab | ||
|
|
bfcdc34faf | ||
|
|
c728e6047d | ||
|
|
4c53a9ba8c | ||
|
|
e10a7da7e3 | ||
|
|
5cc431b1bf | ||
|
|
734aa2fcb5 | ||
|
|
5e37319d9b | ||
|
|
2e9eb6e3e9 | ||
|
|
9ce57b123a | ||
|
|
e793168afa | ||
|
|
d1513424e7 | ||
|
|
1436a01dbe | ||
|
|
b9b936b92a | ||
|
|
adf14bec31 | ||
|
|
ca1403ffea | ||
|
|
06672e439e | ||
|
|
e851701a9e | ||
|
|
9589164008 | ||
|
|
a88b067081 | ||
|
|
b3777e6900 | ||
|
|
d2646e291d | ||
|
|
99ab9ee66b | ||
|
|
08678e74e6 | ||
|
|
62de52ab17 | ||
|
|
d9820d9725 | ||
|
|
fe8a8eeac9 | ||
|
|
dfeb414aff | ||
|
|
69f12a2916 | ||
|
|
2b062e938e | ||
|
|
e0299bd1ae | ||
|
|
ac2f1b56fe | ||
|
|
06d98f6fcf | ||
|
|
bb660d15b2 | ||
|
|
4d73cdefef | ||
|
|
313ba3df80 | ||
|
|
15377c32c2 | ||
|
|
22b52f7c4a | ||
|
|
7055f77c91 | ||
|
|
051fe67176 | ||
|
|
90accfcc48 | ||
|
|
4f99db0c90 | ||
|
|
aeb356bf54 | ||
|
|
0dffa0f333 | ||
|
|
d17f5b8447 | ||
|
|
b5a57b3c66 | ||
|
|
987a3404a9 | ||
|
|
eddc30769f | ||
|
|
4d455650ba | ||
|
|
e2157aab26 | ||
|
|
b277f4bf3f | ||
|
|
4047452b0f | ||
|
|
cb37724879 | ||
|
|
8890893412 | ||
|
|
d0cbda7c0d | ||
|
|
60e7b9ffb0 | ||
|
|
45457c6f76 | ||
|
|
737f41d92b | ||
|
|
180841f364 | ||
|
|
bea40d4c2f | ||
|
|
5f9a054441 | ||
|
|
f90bf1ce7c | ||
|
|
8c4ed2d4da | ||
|
|
0e590a1bbf | ||
|
|
218a096135 | ||
|
|
8407bce370 | ||
|
|
43229f0b99 | ||
|
|
84fa0002b9 | ||
|
|
e79c705b20 | ||
|
|
894d7ce15d | ||
|
|
5830880582 | ||
|
|
caab0f70ff | ||
|
|
641966fcdd | ||
|
|
24c22e9bbf | ||
|
|
795f597bda | ||
|
|
2228663a7e | ||
|
|
0c97df357d | ||
|
|
19f63f1be0 | ||
|
|
fc000caf73 | ||
|
|
78929e0293 | ||
|
|
71e22da987 | ||
|
|
24e99d9654 | ||
|
|
98299da424 | ||
|
|
7014af66b6 | ||
|
|
659bd90027 | ||
|
|
146b0c284b | ||
|
|
4a0ac8807f | ||
|
|
d67734832e | ||
|
|
1673bf026a | ||
|
|
1f29b000a9 | ||
|
|
a6d024123e | ||
|
|
fb1a7239ce | ||
|
|
4f71d508cb | ||
|
|
2072bd61d1 | ||
|
|
6021178b7d | ||
|
|
179b0be2bb | ||
|
|
bf2b45dd4a | ||
|
|
513561234c | ||
|
|
33da990ae7 | ||
|
|
4003946e68 | ||
|
|
21f8d40789 | ||
|
|
d6c698e1d6 | ||
|
|
6c227852ae | ||
|
|
29cb22c4fd | ||
|
|
d040bc9e2d | ||
|
|
abb92f23a6 | ||
|
|
da5c86bb69 | ||
|
|
a0d428b12c | ||
|
|
e22fe20e23 | ||
|
|
1e6659aff9 | ||
|
|
60b32d5b05 | ||
|
|
e2ee9053a0 | ||
|
|
d2f0422ecc | ||
|
|
bfd97da626 | ||
|
|
1fd163f0bb | ||
|
|
d15ce575df | ||
|
|
9999ff5a89 | ||
|
|
4653941082 | ||
|
|
fa509661ab | ||
|
|
d9a289bf18 | ||
|
|
98c76b713d | ||
|
|
05ed917a56 | ||
|
|
b833806ec7 | ||
|
|
7fdef3418a | ||
|
|
49e14ec542 | ||
|
|
efd9244684 | ||
|
|
318f2d1f8c | ||
|
|
92fa1cf052 | ||
|
|
17c6eb1680 | ||
|
|
7c6af568d8 | ||
|
|
23c9c6826e | ||
|
|
b08fda5e10 | ||
|
|
5ec3a49377 | ||
|
|
1c728ae432 | ||
|
|
fd25c735c1 | ||
|
|
7de33907c5 | ||
|
|
ec021be16c | ||
|
|
8b6826ffa4 | ||
|
|
00cc1ffe74 | ||
|
|
2384fdbaad | ||
|
|
08a93d81d7 | ||
|
|
86911100df | ||
|
|
ff01656268 | ||
|
|
d0ea6d9e8d | ||
|
|
96ca3d5e38 | ||
|
|
3a569499cb | ||
|
|
545b19f978 | ||
|
|
d98be19c9a | ||
|
|
4826898c55 | ||
|
|
482b592f77 | ||
|
|
939ebfe47b | ||
|
|
c6dee1339b | ||
|
|
23f8c3ad3c | ||
|
|
81c1b88376 | ||
|
|
c4a85db698 | ||
|
|
e6eda45c04 | ||
|
|
eb76362de4 | ||
|
|
1cde14859b | ||
|
|
c752b98995 | ||
|
|
1f792ca418 | ||
|
|
b22e08b1eb | ||
|
|
2b5029cc38 | ||
|
|
9e936cb57b | ||
|
|
e8fd2c1b3c | ||
|
|
977fbf54ee | ||
|
|
3e5c91415d | ||
|
|
d60b855f74 | ||
|
|
4146192b6d | ||
|
|
26ee0ff48f | ||
|
|
1763d80d5f | ||
|
|
a594e5147c | ||
|
|
e51ea1a619 | ||
|
|
37c9260dca | ||
|
|
e1a8f4f5db | ||
|
|
b7b0b39afc | ||
|
|
af797489e8 | ||
|
|
b1b756b179 | ||
|
|
9e3372df72 | ||
|
|
657df7a728 | ||
|
|
944f0b694b | ||
|
|
efd409da17 | ||
|
|
08d60c554c | ||
|
|
a428ea7beb | ||
|
|
f69878b082 | ||
|
|
c5ffc2882b | ||
|
|
765691751a | ||
|
|
f19e5977c2 | ||
|
|
8f8b9af862 | ||
|
|
3e7dc71995 | ||
|
|
2a25cd8686 | ||
|
|
7664839135 | ||
|
|
47daebc65a | ||
|
|
0d18b944b6 | ||
|
|
951b5384a3 | ||
|
|
11547ecaa3 |
2
.github/actions/report-size/action.yml
vendored
2
.github/actions/report-size/action.yml
vendored
@@ -44,7 +44,7 @@ runs:
|
||||
steps:
|
||||
# Checkout branch to compare to [required]
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.branch }}
|
||||
path: br-base
|
||||
|
||||
40
.github/instructions/nx.instructions.md
vendored
Normal file
40
.github/instructions/nx.instructions.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
|
||||
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
|
||||
8
.github/workflows/dev.yml
vendored
8
.github/workflows/dev.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- check-affected
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- test_dev
|
||||
- check-affected
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
- dockerfile: Dockerfile
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
|
||||
6
.github/workflows/main-docker.yml
vendored
6
.github/workflows/main-docker.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- dockerfile: Dockerfile
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
6
.github/workflows/nightly.yml
vendored
6
.github/workflows/nightly.yml
vendored
@@ -27,6 +27,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
nightly-electron:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
name: Deploy nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
forge_platform: win32
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
@@ -96,6 +97,7 @@ jobs:
|
||||
path: apps/desktop/upload
|
||||
|
||||
nightly-server:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
name: Deploy server nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -108,7 +110,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-server
|
||||
|
||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
filter: tree:0
|
||||
fetch-depth: 0
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
forge_platform: win32
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-server
|
||||
@@ -101,13 +101,13 @@ jobs:
|
||||
steps:
|
||||
- run: mkdir upload
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: |
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
|
||||
11
.github/workflows/unblock_signing.yml
vendored
Normal file
11
.github/workflows/unblock_signing.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Unblock signing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unblock-win-signing:
|
||||
runs-on: win-signing
|
||||
steps:
|
||||
- run: |
|
||||
cat ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
rm ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
|
||||
6
.idea/.gitignore
generated
vendored
6
.idea/.gitignore
generated
vendored
@@ -1,6 +0,0 @@
|
||||
# Default ignored files
|
||||
/workspace.xml
|
||||
|
||||
# Datasource local storage ignored files
|
||||
/dataSources.local.xml
|
||||
/dataSources/
|
||||
15
.idea/codeStyles/Project.xml
generated
15
.idea/codeStyles/Project.xml
generated
@@ -1,15 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="OTHER_INDENT_OPTIONS">
|
||||
<value>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</value>
|
||||
</option>
|
||||
<codeStyleSettings language="JSON">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="document.db" uuid="2a4ac1e6-b828-4a2a-8e4a-3f59f10aff26">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/document.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
4
.idea/encodings.xml
generated
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
||||
</project>
|
||||
15
.idea/git_toolbox_prj.xml
generated
15
.idea/git_toolbox_prj.xml
generated
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
11
.idea/inspectionProfiles/Project_Default.xml
generated
11
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,11 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/jsLibraryMappings.xml
generated
6
.idea/jsLibraryMappings.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptLibraryMappings">
|
||||
<includedPredefinedLibrary name="Node.js Core" />
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/jsLinters/jslint.xml
generated
9
.idea/jsLinters/jslint.xml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JSLintConfiguration">
|
||||
<option devel="true" />
|
||||
<option es6="true" />
|
||||
<option maxerr="50" />
|
||||
<option node="true" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/misc.xml
generated
8
.idea/misc.xml
generated
@@ -1,8 +0,0 @@
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_16" default="true" project-jdk-name="openjdk-16" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/trilium.iml" filepath="$PROJECT_DIR$/trilium.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/sqldialects.xml
generated
7
.idea/sqldialects.xml
generated
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$" dialect="SQLite" />
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
4
.vscode/i18n-ally-custom-framework.yml
vendored
4
.vscode/i18n-ally-custom-framework.yml
vendored
@@ -3,6 +3,7 @@
|
||||
languageIds:
|
||||
- javascript
|
||||
- typescript
|
||||
- typescriptreact
|
||||
- html
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
@@ -25,9 +26,10 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
refactorTemplates:
|
||||
- t("$1")
|
||||
- {t("$1")}
|
||||
- ${t("$1")}
|
||||
- <%= t("$1") %>
|
||||
|
||||
|
||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||
monopoly: true
|
||||
monopoly: true
|
||||
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"nx-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:9461/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -35,5 +35,6 @@
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
}
|
||||
},
|
||||
"nxConsole.generateAiAgentRules": true
|
||||
}
|
||||
161
CLAUDE.md
Normal file
161
CLAUDE.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
- `pnpm install` - Install all dependencies
|
||||
- `corepack enable` - Enable pnpm if not available
|
||||
|
||||
### 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
|
||||
|
||||
### Testing
|
||||
- `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
|
||||
- **apps/**: Runnable applications
|
||||
- `client/` - Frontend application (shared by server and desktop)
|
||||
- `server/` - Node.js server with web interface
|
||||
- `desktop/` - Electron desktop application
|
||||
- `web-clipper/` - Browser extension for saving web content
|
||||
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
|
||||
|
||||
- **packages/**: Shared libraries
|
||||
- `commons/` - Shared interfaces and utilities
|
||||
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
|
||||
- `codemirror/` - Code editor customizations
|
||||
- `highlightjs/` - Syntax highlighting
|
||||
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
|
||||
|
||||
### Core Architecture Patterns
|
||||
|
||||
#### Three-Layer Cache System
|
||||
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
|
||||
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
|
||||
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
|
||||
|
||||
#### Entity System
|
||||
Core entities are defined in `apps/server/src/becca/entities/`:
|
||||
- `BNote` - Notes with content and metadata
|
||||
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
|
||||
- `BAttribute` - Key-value metadata attached to notes
|
||||
- `BRevision` - Note version history
|
||||
- `BOption` - Application configuration
|
||||
|
||||
#### Widget-Based UI
|
||||
Frontend uses a widget system (`apps/client/src/widgets/`):
|
||||
- `BasicWidget` - Base class for all UI components
|
||||
- `NoteContextAwareWidget` - Widgets that respond to note changes
|
||||
- `RightPanelWidget` - Widgets displayed in the right panel
|
||||
- Type-specific widgets in `type_widgets/` directory
|
||||
|
||||
#### API Architecture
|
||||
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
|
||||
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
|
||||
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
|
||||
|
||||
### Key Files for Understanding Architecture
|
||||
|
||||
1. **Application Entry Points**:
|
||||
- `apps/server/src/main.ts` - Server startup
|
||||
- `apps/client/src/desktop.ts` - Client initialization
|
||||
|
||||
2. **Core Services**:
|
||||
- `apps/server/src/becca/becca.ts` - Backend data management
|
||||
- `apps/client/src/services/froca.ts` - Frontend data synchronization
|
||||
- `apps/server/src/services/backend_script_api.ts` - Scripting API
|
||||
|
||||
3. **Database Schema**:
|
||||
- `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
|
||||
|
||||
Trilium supports multiple note types, each with specialized widgets:
|
||||
- **Text**: Rich text with CKEditor5 (markdown import/export)
|
||||
- **Code**: Syntax-highlighted code editing with CodeMirror
|
||||
- **File**: Binary file attachments
|
||||
- **Image**: Image display with editing capabilities
|
||||
- **Canvas**: Drawing/diagramming with Excalidraw
|
||||
- **Mermaid**: Diagram generation
|
||||
- **Relation Map**: Visual note relationship mapping
|
||||
- **Web View**: Embedded web pages
|
||||
- **Doc/Book**: Hierarchical documentation structure
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Testing Strategy
|
||||
- Server tests run sequentially due to shared database
|
||||
- Client tests can run in parallel
|
||||
- E2E tests use Playwright for both server and desktop apps
|
||||
- Build validation tests check artifact integrity
|
||||
|
||||
### Scripting System
|
||||
Trilium provides powerful user scripting capabilities:
|
||||
- Frontend scripts run in browser context
|
||||
- Backend scripts run in Node.js context with full API access
|
||||
- Script API documentation available in `docs/Script API/`
|
||||
|
||||
### Internationalization
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
- CSRF protection for API endpoints
|
||||
- OpenID and TOTP authentication support
|
||||
- Sanitization of user-generated content
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Note Types
|
||||
1. Create widget in `apps/client/src/widgets/type_widgets/`
|
||||
2. Register in `apps/client/src/services/note_types.ts`
|
||||
3. Add backend handling in `apps/server/src/services/notes.ts`
|
||||
|
||||
### Extending Search
|
||||
- Search expressions handled in `apps/server/src/services/search/`
|
||||
- Add new search operators in search context files
|
||||
|
||||
### Custom CKEditor Plugins
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
## Build System Notes
|
||||
- Uses NX for monorepo management with build caching
|
||||
- Vite for fast development builds
|
||||
- ESBuild for production optimization
|
||||
- pnpm workspaces for dependency management
|
||||
- Docker support with multi-stage builds
|
||||
21
README.md
21
README.md
@@ -1,10 +1,9 @@
|
||||
# Trilium Notes
|
||||
|
||||
Donate:  
|
||||
|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
|
||||
 
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
@@ -83,7 +82,7 @@ Feel free to join our official conversations. We would love to hear what feature
|
||||
|
||||
### Windows / MacOS
|
||||
|
||||
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
|
||||
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -91,7 +90,7 @@ If your distribution is listed in the table below, use your distribution's packa
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
|
||||
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
|
||||
|
||||
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
|
||||
|
||||
@@ -116,6 +115,14 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
|
||||
|
||||
## 💻 Contribute
|
||||
|
||||
### Translations
|
||||
|
||||
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Here's the language coverage we have so far:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Code
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.1",
|
||||
"@stylistic/eslint-plugin": "5.2.0",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@stylistic/eslint-plugin": "5.2.3",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.31.0",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
@@ -49,8 +49,8 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.7",
|
||||
"typedoc-plugin-missing-exports": "4.0.0"
|
||||
"typedoc": "0.28.10",
|
||||
"typedoc-plugin-missing-exports": "4.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"appdmg": "0.6.6"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.97.0",
|
||||
"version": "0.97.2",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -10,14 +10,15 @@
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.31.0",
|
||||
"@eslint/js": "9.33.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.18",
|
||||
"@fullcalendar/daygrid": "6.1.18",
|
||||
"@fullcalendar/interaction": "6.1.18",
|
||||
"@fullcalendar/list": "6.1.18",
|
||||
"@fullcalendar/multimonth": "6.1.18",
|
||||
"@fullcalendar/timegrid": "6.1.18",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
"@fullcalendar/interaction": "6.1.19",
|
||||
"@fullcalendar/list": "6.1.19",
|
||||
"@fullcalendar/multimonth": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
@@ -35,10 +36,9 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.50.1",
|
||||
"globals": "16.3.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next": "25.3.4",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery-hotkeys": "0.2.2",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.22",
|
||||
@@ -46,26 +46,28 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.1.1",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.2",
|
||||
"mind-elixir": "5.0.5",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.26.9",
|
||||
"preact": "10.27.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
"vanilla-js-wheel-zoom": "9.0.4",
|
||||
"photoswipe": "^5.4.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/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.2.7",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"@types/tabulator-tables": "6.2.10",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.1"
|
||||
|
||||
@@ -30,6 +30,7 @@ import type CodeMirror from "@triliumnext/codemirror";
|
||||
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";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
@@ -92,7 +93,9 @@ export type CommandMappings = {
|
||||
closeTocCommand: CommandData;
|
||||
closeHlt: CommandData;
|
||||
showLaunchBarSubtree: CommandData;
|
||||
showRevisions: CommandData;
|
||||
showRevisions: CommandData & {
|
||||
noteId?: string | null;
|
||||
};
|
||||
showLlmChat: CommandData;
|
||||
createAiChat: CommandData;
|
||||
showOptions: CommandData & {
|
||||
@@ -133,6 +136,8 @@ export type CommandMappings = {
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -173,7 +178,7 @@ export type CommandMappings = {
|
||||
deleteNotes: ContextMenuCommandData;
|
||||
importIntoNote: ContextMenuCommandData;
|
||||
exportNote: ContextMenuCommandData;
|
||||
searchInSubtree: ContextMenuCommandData;
|
||||
searchInSubtree: CommandData & { notePath: string; };
|
||||
moveNoteUp: ContextMenuCommandData;
|
||||
moveNoteDown: ContextMenuCommandData;
|
||||
moveNoteUpInHierarchy: ContextMenuCommandData;
|
||||
@@ -262,6 +267,73 @@ export type CommandMappings = {
|
||||
closeThisNoteSplit: CommandData;
|
||||
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
|
||||
jumpToNote: CommandData;
|
||||
commandPalette: CommandData;
|
||||
|
||||
// Keyboard shortcuts
|
||||
backInNoteHistory: CommandData;
|
||||
forwardInNoteHistory: CommandData;
|
||||
forceSaveRevision: CommandData;
|
||||
scrollToActiveNote: CommandData;
|
||||
quickSearch: CommandData;
|
||||
collapseTree: CommandData;
|
||||
createNoteAfter: CommandData;
|
||||
createNoteInto: CommandData;
|
||||
addNoteAboveToSelection: CommandData;
|
||||
addNoteBelowToSelection: CommandData;
|
||||
openNewTab: CommandData;
|
||||
activateNextTab: CommandData;
|
||||
activatePreviousTab: CommandData;
|
||||
openNewWindow: CommandData;
|
||||
toggleTray: CommandData;
|
||||
firstTab: CommandData;
|
||||
secondTab: CommandData;
|
||||
thirdTab: CommandData;
|
||||
fourthTab: CommandData;
|
||||
fifthTab: CommandData;
|
||||
sixthTab: CommandData;
|
||||
seventhTab: CommandData;
|
||||
eigthTab: CommandData;
|
||||
ninthTab: CommandData;
|
||||
lastTab: CommandData;
|
||||
showNoteSource: CommandData;
|
||||
showSQLConsole: CommandData;
|
||||
showBackendLog: CommandData;
|
||||
showCheatsheet: CommandData;
|
||||
showHelp: CommandData;
|
||||
addLinkToText: CommandData;
|
||||
followLinkUnderCursor: CommandData;
|
||||
insertDateTimeToText: CommandData;
|
||||
pasteMarkdownIntoText: CommandData;
|
||||
cutIntoNote: CommandData;
|
||||
addIncludeNoteToText: CommandData;
|
||||
editReadOnlyNote: CommandData;
|
||||
toggleRibbonTabClassicEditor: CommandData;
|
||||
toggleRibbonTabBasicProperties: CommandData;
|
||||
toggleRibbonTabBookProperties: CommandData;
|
||||
toggleRibbonTabFileProperties: CommandData;
|
||||
toggleRibbonTabImageProperties: CommandData;
|
||||
toggleRibbonTabOwnedAttributes: CommandData;
|
||||
toggleRibbonTabInheritedAttributes: CommandData;
|
||||
toggleRibbonTabPromotedAttributes: CommandData;
|
||||
toggleRibbonTabNoteMap: CommandData;
|
||||
toggleRibbonTabNoteInfo: CommandData;
|
||||
toggleRibbonTabNotePaths: CommandData;
|
||||
toggleRibbonTabSimilarNotes: CommandData;
|
||||
toggleRightPane: CommandData;
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
openDevTools: CommandData;
|
||||
findInText: CommandData;
|
||||
toggleLeftPane: CommandData;
|
||||
toggleFullscreen: CommandData;
|
||||
zoomOut: CommandData;
|
||||
zoomIn: CommandData;
|
||||
zoomReset: CommandData;
|
||||
copyWithoutFormatting: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
@@ -299,6 +371,9 @@ export type CommandMappings = {
|
||||
};
|
||||
refreshTouchBar: CommandData;
|
||||
reloadTextEditor: CommandData;
|
||||
chooseNoteType: CommandData & {
|
||||
callback: ChooseNoteTypeCallback
|
||||
}
|
||||
};
|
||||
|
||||
type EventMappings = {
|
||||
|
||||
@@ -30,13 +30,6 @@ interface CreateChildrenResponse {
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (jQuery.hotkeys) {
|
||||
// hot keys are active also inside inputs and content editables
|
||||
jQuery.hotkeys.options.filterInputAcceptingElements = false;
|
||||
jQuery.hotkeys.options.filterContentEditable = false;
|
||||
jQuery.hotkeys.options.filterTextInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
openDevToolsCommand() {
|
||||
@@ -113,7 +106,9 @@ export default class Entrypoints extends Component {
|
||||
if (win.isFullScreenable()) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
}
|
||||
} // outside of electron this is handled by the browser
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
reloadFrontendAppCommand() {
|
||||
|
||||
@@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some book types must always display a note list, even if no children.
|
||||
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
|
||||
// Collections must always display a note list, even if no children.
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "jquery-hotkeys";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "./styles/gallery.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -30,6 +30,7 @@ import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolb
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_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";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@@ -66,4 +67,5 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(true)))
|
||||
.child(new CallToActionDialog());
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 { applyModals } from "./layout_commons.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -174,7 +175,8 @@ export default class MobileLayout {
|
||||
.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 CloseZenButton());
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
|
||||
@@ -18,6 +20,12 @@ 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",
|
||||
@@ -30,7 +38,48 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === "copyImageReferenceToClipboard") {
|
||||
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") {
|
||||
imageService.copyImageReferenceToClipboard($image);
|
||||
} else if (command === "copyImageToClipboard") {
|
||||
try {
|
||||
|
||||
@@ -23,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null;
|
||||
|
||||
// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator,
|
||||
// so they need to be added manually.
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog" | "searchInSubtree";
|
||||
|
||||
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
|
||||
private treeWidget: NoteTreeWidget;
|
||||
@@ -129,7 +129,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
|
||||
@@ -3,6 +3,7 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import glob from "./services/glob.js";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
glob.setupGlobs();
|
||||
|
||||
@@ -79,7 +79,19 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b
|
||||
return $container;
|
||||
}
|
||||
|
||||
const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"];
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
"originalFileName",
|
||||
"fileSize",
|
||||
"template",
|
||||
"inherit",
|
||||
"cssClass",
|
||||
"iconClass",
|
||||
"pageSize",
|
||||
"viewType",
|
||||
"geolocation",
|
||||
"docName",
|
||||
"webViewSrc"
|
||||
];
|
||||
|
||||
async function renderNormalAttributes(note: FNote) {
|
||||
const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();
|
||||
|
||||
@@ -91,10 +91,10 @@ function parseActions(note: FNote) {
|
||||
.filter((action) => !!action);
|
||||
}
|
||||
|
||||
export async function executeBulkActions(parentNoteId: string, actions: BulkAction[]) {
|
||||
export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: [ parentNoteId ],
|
||||
includeDescendants: true,
|
||||
noteIds: targetNoteIds,
|
||||
includeDescendants,
|
||||
actions
|
||||
});
|
||||
|
||||
|
||||
521
apps/client/src/services/ckeditor_photoswipe_integration.ts
Normal file
521
apps/client/src/services/ckeditor_photoswipe_integration.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 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();
|
||||
295
apps/client/src/services/command_registry.ts
Normal file
295
apps/client/src/services/command_registry.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import { t, translationsInitializedPromise } from "./i18n.js";
|
||||
import keyboardActions from "./keyboard_actions.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
export interface CommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
commandName?: CommandNames;
|
||||
handler?: () => Promise<unknown> | null | undefined | void;
|
||||
aliases?: string[];
|
||||
source?: "manual" | "keyboard-action";
|
||||
/** Reference to the original keyboard action for scope checking. */
|
||||
keyboardAction?: ActionKeyboardShortcut;
|
||||
}
|
||||
|
||||
class CommandRegistry {
|
||||
private commands: Map<string, CommandDefinition> = new Map();
|
||||
private aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.loadCommands();
|
||||
}
|
||||
|
||||
private async loadCommands() {
|
||||
await translationsInitializedPromise;
|
||||
this.registerDefaultCommands();
|
||||
await this.loadKeyboardActionsAsync();
|
||||
}
|
||||
|
||||
private registerDefaultCommands() {
|
||||
this.register({
|
||||
id: "export-note",
|
||||
name: t("command_palette.export_note_title"),
|
||||
description: t("command_palette.export_note_description"),
|
||||
icon: "bx bx-export",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("showExportDialog", {
|
||||
notePath,
|
||||
defaultType: "single"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-attachments",
|
||||
name: t("command_palette.show_attachments_title"),
|
||||
description: t("command_palette.show_attachments_description"),
|
||||
icon: "bx bx-paperclip",
|
||||
handler: () => appContext.triggerCommand("showAttachments")
|
||||
});
|
||||
|
||||
// Special search commands with custom logic
|
||||
this.register({
|
||||
id: "search-notes",
|
||||
name: t("command_palette.search_notes_title"),
|
||||
description: t("command_palette.search_notes_description"),
|
||||
icon: "bx bx-search",
|
||||
handler: () => appContext.triggerCommand("searchNotes", {})
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "search-in-subtree",
|
||||
name: t("command_palette.search_subtree_title"),
|
||||
description: t("command_palette.search_subtree_description"),
|
||||
icon: "bx bx-search-alt",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("searchInSubtree", { notePath });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-search-history",
|
||||
name: t("command_palette.search_history_title"),
|
||||
description: t("command_palette.search_history_description"),
|
||||
icon: "bx bx-history",
|
||||
handler: () => appContext.triggerCommand("showSearchHistory")
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-launch-bar",
|
||||
name: t("command_palette.configure_launch_bar_title"),
|
||||
description: t("command_palette.configure_launch_bar_description"),
|
||||
icon: "bx bx-sidebar",
|
||||
handler: () => appContext.triggerCommand("showLaunchBarSubtree")
|
||||
});
|
||||
}
|
||||
|
||||
private async loadKeyboardActionsAsync() {
|
||||
try {
|
||||
const actions = await keyboardActions.getActions();
|
||||
this.registerKeyboardActions(actions);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keyboard actions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private registerKeyboardActions(actions: ActionKeyboardShortcut[]) {
|
||||
for (const action of actions) {
|
||||
// Skip actions that we've already manually registered
|
||||
if (this.commands.has(action.actionName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that don't have a description (likely separators)
|
||||
if (!action.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Electron-only actions if not in Electron environment
|
||||
if (action.isElectronOnly && !utils.isElectron()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that should not appear in the command palette
|
||||
if (action.ignoreFromCommandPalette) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the primary shortcut (first one in the list)
|
||||
const primaryShortcut = action.effectiveShortcuts?.[0];
|
||||
|
||||
let name = action.friendlyName;
|
||||
if (action.scope === "note-tree") {
|
||||
name = t("command_palette.tree-action-name", { name: action.friendlyName });
|
||||
}
|
||||
|
||||
// Create a command definition from the keyboard action
|
||||
const commandDef: CommandDefinition = {
|
||||
id: action.actionName,
|
||||
name,
|
||||
description: action.description,
|
||||
icon: action.iconClass,
|
||||
shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined,
|
||||
commandName: action.actionName as CommandNames,
|
||||
source: "keyboard-action",
|
||||
keyboardAction: action
|
||||
};
|
||||
|
||||
this.register(commandDef);
|
||||
}
|
||||
}
|
||||
|
||||
private formatShortcut(shortcut: string): string {
|
||||
// Convert electron accelerator format to display format
|
||||
return shortcut
|
||||
.replace(/CommandOrControl/g, 'Ctrl')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
register(command: CommandDefinition) {
|
||||
this.commands.set(command.id, command);
|
||||
|
||||
// Register aliases
|
||||
if (command.aliases) {
|
||||
for (const alias of command.aliases) {
|
||||
this.aliases.set(alias.toLowerCase(), command.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCommand(id: string): CommandDefinition | undefined {
|
||||
return this.commands.get(id);
|
||||
}
|
||||
|
||||
getAllCommands(): CommandDefinition[] {
|
||||
const commands = Array.from(this.commands.values());
|
||||
|
||||
// Sort commands by name
|
||||
commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
searchCommands(query: string): CommandDefinition[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const results: { command: CommandDefinition; score: number }[] = [];
|
||||
|
||||
for (const command of this.commands.values()) {
|
||||
let score = 0;
|
||||
|
||||
// Exact match on name
|
||||
if (command.name.toLowerCase() === normalizedQuery) {
|
||||
score = 100;
|
||||
}
|
||||
// Name starts with query
|
||||
else if (command.name.toLowerCase().startsWith(normalizedQuery)) {
|
||||
score = 80;
|
||||
}
|
||||
// Name contains query
|
||||
else if (command.name.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 60;
|
||||
}
|
||||
// Description contains query
|
||||
else if (command.description?.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 40;
|
||||
}
|
||||
// Check aliases
|
||||
else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) {
|
||||
score = 50;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ command, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (highest first) and then by name
|
||||
results.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.command.name.localeCompare(b.command.name);
|
||||
});
|
||||
|
||||
return results.map(r => r.command);
|
||||
}
|
||||
|
||||
async executeCommand(commandId: string) {
|
||||
const command = this.getCommand(commandId);
|
||||
if (!command) {
|
||||
console.error(`Command not found: ${commandId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute custom handler if provided
|
||||
if (command.handler) {
|
||||
await command.handler();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keyboard action with scope-aware execution
|
||||
if (command.keyboardAction && command.commandName) {
|
||||
if (command.keyboardAction.scope === "note-tree") {
|
||||
this.executeWithNoteTreeFocus(command.commandName);
|
||||
} else if (command.keyboardAction.scope === "text-detail") {
|
||||
this.executeWithTextDetail(command.commandName);
|
||||
} else {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for commands without keyboard action reference
|
||||
if (command.commandName) {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Command ${commandId} has no handler or commandName`);
|
||||
}
|
||||
|
||||
private executeWithNoteTreeFocus(actionName: CommandNames) {
|
||||
const tree = document.querySelector(".tree-wrapper") as HTMLElement;
|
||||
if (!tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget;
|
||||
const activeNode = treeComponent.getActiveNode();
|
||||
treeComponent.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId,
|
||||
node: activeNode
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithTextDetail(actionName: CommandNames) {
|
||||
const typeWidget = await appContext.tabManager.getActiveContext()?.getTypeWidget();
|
||||
if (!typeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
typeWidget.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
export default commandRegistry;
|
||||
@@ -65,6 +65,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
|
||||
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
|
||||
} else if (entity instanceof FNote) {
|
||||
$renderedContent
|
||||
.css("display", "flex")
|
||||
.css("flex-direction", "column");
|
||||
$renderedContent.append(
|
||||
$("<div>")
|
||||
.css("display", "flex")
|
||||
@@ -72,8 +75,33 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
.css("align-items", "center")
|
||||
.css("height", "100%")
|
||||
.css("font-size", "500%")
|
||||
.css("flex-grow", "1")
|
||||
.append($("<span>").addClass(entity.getIcon()))
|
||||
);
|
||||
|
||||
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
|
||||
const $footer = $("<footer>")
|
||||
.addClass("webview-footer");
|
||||
const $openButton = $(`
|
||||
<button class="file-open btn btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
${t("content_renderer.open_externally")}
|
||||
</button>
|
||||
`)
|
||||
.appendTo($footer)
|
||||
.on("click", () => {
|
||||
const webViewSrc = entity.getLabelValue("webViewSrc");
|
||||
if (webViewSrc) {
|
||||
if (utils.isElectron()) {
|
||||
const electron = utils.dynamicRequire("electron");
|
||||
electron.shell.openExternal(webViewSrc);
|
||||
} else {
|
||||
window.open(webViewSrc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
});
|
||||
$footer.appendTo($renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
|
||||
387
apps/client/src/services/gallery_manager.spec.ts
Normal file
387
apps/client/src/services/gallery_manager.spec.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
987
apps/client/src/services/gallery_manager.ts
Normal file
987
apps/client/src/services/gallery_manager.ts
Normal file
@@ -0,0 +1,987 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -6,6 +6,11 @@ import type { Locale } from "@triliumnext/commons";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = (options.get("locale") as string) || "en";
|
||||
|
||||
@@ -19,6 +24,8 @@ export async function initLocale() {
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
translationsInitializedPromise.resolve();
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
|
||||
597
apps/client/src/services/image_annotations.ts
Normal file
597
apps/client/src/services/image_annotations.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* 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();
|
||||
877
apps/client/src/services/image_comparison.ts
Normal file
877
apps/client/src/services/image_comparison.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* 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();
|
||||
874
apps/client/src/services/image_editor.ts
Normal file
874
apps/client/src/services/image_editor.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
/**
|
||||
* 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();
|
||||
369
apps/client/src/services/image_error_handler.ts
Normal file
369
apps/client/src/services/image_error_handler.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
839
apps/client/src/services/image_exif.ts
Normal file
839
apps/client/src/services/image_exif.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
/**
|
||||
* 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();
|
||||
681
apps/client/src/services/image_sharing.ts
Normal file
681
apps/client/src/services/image_sharing.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -2,21 +2,15 @@ import server from "./server.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
const keyboardActionRepo: Record<string, Action> = {};
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
export interface Action {
|
||||
actionName: CommandNames;
|
||||
effectiveShortcuts: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => {
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
actions = actions.filter((a) => !!a.actionName); // filter out separators
|
||||
|
||||
for (const action of actions) {
|
||||
action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
action.effectiveShortcuts = (action.effectiveShortcuts ?? []).filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
|
||||
keyboardActionRepo[action.actionName] = action;
|
||||
}
|
||||
@@ -38,7 +32,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
const actions = await getActionsForScope(scope);
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +40,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +74,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
const action = await getAction(actionName, true);
|
||||
|
||||
if (action) {
|
||||
const keyboardActions = action.effectiveShortcuts.join(", ");
|
||||
const keyboardActions = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (keyboardActions || $(el).text() !== "not set") {
|
||||
$(el).text(keyboardActions);
|
||||
@@ -99,7 +93,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
|
||||
if (action) {
|
||||
const title = $(el).attr("title");
|
||||
const shortcuts = action.effectiveShortcuts.join(", ");
|
||||
const shortcuts = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (title?.includes(shortcuts)) {
|
||||
return;
|
||||
|
||||
552
apps/client/src/services/media_viewer.ts
Normal file
552
apps/client/src/services/media_viewer.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
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();
|
||||
@@ -3,6 +3,7 @@ import appContext from "../components/app_context.js";
|
||||
import noteCreateService from "./note_create.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import commandRegistry from "./command_registry.js";
|
||||
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
|
||||
|
||||
// this key needs to have this value, so it's hit by the tooltip
|
||||
@@ -29,13 +30,16 @@ export interface Suggestion {
|
||||
notePathTitle?: string;
|
||||
notePath?: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
action?: string | "create-note" | "search-notes" | "external-link";
|
||||
action?: string | "create-note" | "search-notes" | "external-link" | "command";
|
||||
parentNoteId?: string;
|
||||
icon?: string;
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
container?: HTMLElement;
|
||||
export interface Options {
|
||||
container?: HTMLElement | null;
|
||||
fastSearch?: boolean;
|
||||
allowCreatingNotes?: boolean;
|
||||
allowJumpToSearchNotes?: boolean;
|
||||
@@ -44,6 +48,8 @@ interface Options {
|
||||
hideGoToSelectedNoteButton?: boolean;
|
||||
/** If set, hides all right-side buttons in the autocomplete dropdown */
|
||||
hideAllButtons?: boolean;
|
||||
/** If set, enables command palette mode */
|
||||
isCommandPalette?: boolean;
|
||||
}
|
||||
|
||||
async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
@@ -73,6 +79,31 @@ async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
}
|
||||
|
||||
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) {
|
||||
// Check if we're in command mode
|
||||
if (options.isCommandPalette && term.startsWith(">")) {
|
||||
const commandQuery = term.substring(1).trim();
|
||||
|
||||
// Get commands (all if no query, filtered if query provided)
|
||||
const commands = commandQuery.length === 0
|
||||
? commandRegistry.getAllCommands()
|
||||
: commandRegistry.searchCommands(commandQuery);
|
||||
|
||||
// Convert commands to suggestions
|
||||
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
|
||||
action: "command",
|
||||
commandId: cmd.id,
|
||||
noteTitle: cmd.name,
|
||||
notePathTitle: `>${cmd.name}`,
|
||||
highlightedNotePathTitle: cmd.name,
|
||||
commandDescription: cmd.description,
|
||||
commandShortcut: cmd.shortcut,
|
||||
icon: cmd.icon
|
||||
}));
|
||||
|
||||
cb(commandSuggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
const fastSearch = options.fastSearch === false ? false : true;
|
||||
if (fastSearch === false) {
|
||||
if (term.trim().length === 0) {
|
||||
@@ -146,6 +177,12 @@ function showRecentNotes($el: JQuery<HTMLElement>) {
|
||||
$el.trigger("focus");
|
||||
}
|
||||
|
||||
function showAllCommands($el: JQuery<HTMLElement>) {
|
||||
searchDelay = 0;
|
||||
$el.setSelectedNotePath("");
|
||||
$el.autocomplete("val", ">").autocomplete("open");
|
||||
}
|
||||
|
||||
function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
|
||||
const searchString = $el.autocomplete("val") as unknown as string;
|
||||
if (options.fastSearch === false || searchString?.trim().length === 0) {
|
||||
@@ -270,7 +307,24 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
},
|
||||
displayKey: "notePathTitle",
|
||||
templates: {
|
||||
suggestion: (suggestion) => `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`
|
||||
suggestion: (suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
let html = `<div class="command-suggestion">`;
|
||||
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
|
||||
html += `<div class="command-content">`;
|
||||
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
|
||||
if (suggestion.commandDescription) {
|
||||
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (suggestion.commandShortcut) {
|
||||
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
cache: false
|
||||
@@ -280,6 +334,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
|
||||
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
|
||||
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
$el.autocomplete("close");
|
||||
$el.trigger("autocomplete:commandselected", [suggestion]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestion.action === "external-link") {
|
||||
$el.setSelectedNotePath(null);
|
||||
$el.setSelectedExternalLink(suggestion.externalLink);
|
||||
@@ -392,10 +452,26 @@ function init() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function which triggers the display of recent notes in the autocomplete input and focuses it.
|
||||
*
|
||||
* @param inputElement - The input element to trigger recent notes on.
|
||||
*/
|
||||
export function triggerRecentNotes(inputElement: HTMLInputElement | null | undefined) {
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $el = $(inputElement);
|
||||
showRecentNotes($el);
|
||||
$el.trigger("focus").trigger("select");
|
||||
}
|
||||
|
||||
export default {
|
||||
autocompleteSourceForCKEditor,
|
||||
initNoteAutocomplete,
|
||||
showRecentNotes,
|
||||
showAllCommands,
|
||||
setText,
|
||||
init
|
||||
};
|
||||
|
||||
@@ -109,8 +109,6 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
|
||||
|
||||
async function chooseNoteType() {
|
||||
return new Promise<ChooseNoteTypeResponse>((res) => {
|
||||
// TODO: Remove ignore after callback for chooseNoteType is defined in app_context.ts
|
||||
//@ts-ignore
|
||||
appContext.triggerCommand("chooseNoteType", { callback: res });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -6,8 +7,9 @@ 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 = "list" | "grid" | "calendar" | "table" | "geoMap";
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
@@ -23,7 +25,7 @@ export default class NoteListRenderer {
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
const viewType = parentNote.getLabelValue("viewType");
|
||||
|
||||
if (!["list", "grid", "calendar", "table", "geoMap"].includes(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 {
|
||||
@@ -57,6 +59,8 @@ export default class NoteListRenderer {
|
||||
return new TableView(args);
|
||||
case "geoMap":
|
||||
return new GeoView(args);
|
||||
case "board":
|
||||
return new BoardView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
|
||||
@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
let dismissTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function setupGlobalTooltip() {
|
||||
$(document).on("mouseenter", "a", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
|
||||
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
|
||||
$(document).on("click", (e) => {
|
||||
|
||||
541
apps/client/src/services/photoswipe_mobile_a11y.spec.ts
Normal file
541
apps/client/src/services/photoswipe_mobile_a11y.spec.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1756
apps/client/src/services/photoswipe_mobile_a11y.ts
Normal file
1756
apps/client/src/services/photoswipe_mobile_a11y.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
@@ -17,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
323
apps/client/src/services/shortcuts.spec.ts
Normal file
323
apps/client/src/services/shortcuts.spec.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
default: {
|
||||
isDesktop: () => true
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock jQuery globally since it's used in the shortcuts module
|
||||
const mockElement = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
};
|
||||
|
||||
const mockJQuery = vi.fn(() => [mockElement]);
|
||||
(mockJQuery as any).length = 1;
|
||||
mockJQuery[0] = mockElement;
|
||||
|
||||
(global as any).$ = mockJQuery as any;
|
||||
global.document = mockElement as any;
|
||||
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any active bindings after each test
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
});
|
||||
|
||||
describe("normalizeShortcut", () => {
|
||||
it("should normalize shortcut to lowercase and remove whitespace", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
|
||||
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
|
||||
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
|
||||
});
|
||||
|
||||
it("should handle empty or null shortcuts", () => {
|
||||
expect(shortcuts.normalizeShortcut("")).toBe("");
|
||||
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
|
||||
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should handle shortcuts with multiple spaces", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
|
||||
});
|
||||
|
||||
it("should warn about malformed shortcuts", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
shortcuts.normalizeShortcut("ctrl+");
|
||||
shortcuts.normalizeShortcut("+a");
|
||||
shortcuts.normalizeShortcut("ctrl++a");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
const event = createKeyboardEvent("a", "KeyA");
|
||||
expect(keyMatches(event, "a")).toBe(true);
|
||||
expect(keyMatches(event, "A")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match number keys using digit codes", () => {
|
||||
const event = createKeyboardEvent("1", "Digit1");
|
||||
expect(keyMatches(event, "1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match special keys using key mapping", () => {
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
|
||||
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
|
||||
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
|
||||
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
|
||||
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match function keys", () => {
|
||||
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
|
||||
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle undefined or null keys", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
|
||||
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesShortcut", () => {
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
code?: string;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
}) => ({
|
||||
key: options.key,
|
||||
code: options.code || `Key${options.key.toUpperCase()}`,
|
||||
ctrlKey: options.ctrlKey || false,
|
||||
altKey: options.altKey || false,
|
||||
shiftKey: options.shiftKey || false,
|
||||
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);
|
||||
|
||||
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
|
||||
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match complex modifier combinations", () => {
|
||||
const event = createKeyboardEvent({
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
});
|
||||
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match when modifiers don't match", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "alt+a")).toBe(false);
|
||||
expect(matchesShortcut(event, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle alternative modifier names", () => {
|
||||
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
|
||||
|
||||
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
|
||||
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
|
||||
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty or invalid shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "")).toBe(false);
|
||||
expect(matchesShortcut(event, null as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle invalid events", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(matchesShortcut(null as any, "a")).toBe(false);
|
||||
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should warn about invalid shortcut formats", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
|
||||
matchesShortcut(event, "ctrl+");
|
||||
matchesShortcut(event, "+");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindGlobalShortcut", () => {
|
||||
it("should bind a global shortcut", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove previous bindings when namespace is reused", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindElShortcut", () => {
|
||||
it("should bind shortcut to specific element", () => {
|
||||
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const mockJQueryEl = [mockEl] as any;
|
||||
mockJQueryEl.length = 1;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
const emptyJQuery = [] as any;
|
||||
emptyJQuery.length = 0;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeGlobalShortcut", () => {
|
||||
it("should remove shortcuts for a specific namespace", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handling", () => {
|
||||
it.skip("should call handler when shortcut matches", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
// Get the listener that was registered
|
||||
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// First verify that matchesShortcut works directly
|
||||
const testEvent = {
|
||||
type: "keydown",
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Test matchesShortcut directly first
|
||||
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
|
||||
|
||||
// Now test the actual listener
|
||||
listener(testEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(testEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(testEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler for non-keyboard events", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-keyboard event
|
||||
const event = {
|
||||
type: "click"
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler when shortcut doesn't match", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-matching keydown event
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
shortcut: string;
|
||||
handler: Handler;
|
||||
namespace: string | null;
|
||||
listener: (evt: Event) => void;
|
||||
}
|
||||
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
@@ -15,38 +26,167 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
if (utils.isDesktop()) {
|
||||
keyboardShortcut = normalizeShortcut(keyboardShortcut);
|
||||
|
||||
let eventName = "keydown";
|
||||
|
||||
// If namespace is provided, remove all previous bindings for this namespace
|
||||
if (namespace) {
|
||||
eventName += `.${namespace}`;
|
||||
|
||||
// if there's a namespace, then we replace the existing event handler with the new one
|
||||
$el.off(eventName);
|
||||
removeNamespaceBindings(namespace);
|
||||
}
|
||||
|
||||
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut) {
|
||||
$el.bind(eventName, keyboardShortcut, (e) => {
|
||||
if (handler) {
|
||||
handler(e);
|
||||
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut && handler) {
|
||||
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
|
||||
|
||||
const listener = (evt: Event) => {
|
||||
// Only handle keyboard events
|
||||
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
const e = evt as KeyboardEvent;
|
||||
if (matchesShortcut(e, keyboardShortcut)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
element,
|
||||
shortcut: keyboardShortcut,
|
||||
handler,
|
||||
namespace,
|
||||
listener
|
||||
};
|
||||
|
||||
const key = namespace || 'global';
|
||||
if (!activeBindings.has(key)) {
|
||||
activeBindings.set(key, []);
|
||||
}
|
||||
activeBindings.get(key)!.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||
if (!shortcut) return false;
|
||||
|
||||
// Ensure we have a proper KeyboardEvent with key property
|
||||
if (!e || typeof e.key !== 'string') {
|
||||
console.warn('matchesShortcut called with invalid event:', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = shortcut.toLowerCase().split('+');
|
||||
const key = parts[parts.length - 1]; // Last part is the actual key
|
||||
const modifiers = parts.slice(0, -1); // Everything before is modifiers
|
||||
|
||||
// Defensive check - ensure we have a valid key
|
||||
if (!key || key.trim() === '') {
|
||||
console.warn('Invalid shortcut format:', shortcut);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the main key matches
|
||||
if (!keyMatches(e, key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
|
||||
const expectedAlt = modifiers.includes('alt');
|
||||
const expectedShift = modifiers.includes('shift');
|
||||
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
|
||||
|
||||
return e.ctrlKey === expectedCtrl &&
|
||||
e.altKey === expectedAlt &&
|
||||
e.shiftKey === expectedShift &&
|
||||
e.metaKey === expectedMeta;
|
||||
}
|
||||
|
||||
export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
// Defensive check for undefined/null key
|
||||
if (!key) {
|
||||
console.warn('keyMatches called with undefined/null key');
|
||||
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);
|
||||
}
|
||||
|
||||
// For number keys, use the physical key code regardless of modifiers
|
||||
// This works across all keyboard layouts
|
||||
if (key >= '0' && key <= '9') {
|
||||
return e.code === `Digit${key}`;
|
||||
}
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
return e.key.toLowerCase() === key.toLowerCase() ||
|
||||
e.code.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize to the form expected by the jquery.hotkeys.js
|
||||
* Simple normalization - just lowercase and trim whitespace
|
||||
*/
|
||||
function normalizeShortcut(shortcut: string): string {
|
||||
if (!shortcut) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
|
||||
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
|
||||
|
||||
// Warn about potentially problematic shortcuts
|
||||
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
|
||||
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -51,6 +51,14 @@ export default class SpacedUpdate {
|
||||
this.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update interval for the spaced update.
|
||||
* @param interval The update interval in milliseconds.
|
||||
*/
|
||||
setUpdateInterval(interval: number) {
|
||||
this.updateInterval = interval;
|
||||
}
|
||||
|
||||
triggerUpdate() {
|
||||
if (!this.changed) {
|
||||
return;
|
||||
|
||||
@@ -36,7 +36,9 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
|
||||
const $copyButton = $("<button>")
|
||||
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
|
||||
.attr("title", t("code_block.copy_title"))
|
||||
.on("click", () => {
|
||||
.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isShare) {
|
||||
copyTextWithToast($codeBlock.text());
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "jquery";
|
||||
import "jquery-hotkeys";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
@@ -29,6 +29,14 @@ async function formatCodeBlocks() {
|
||||
await formatCodeBlocks($("#content"));
|
||||
}
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
@@ -47,8 +55,11 @@ async function fetchNote(noteId: string | null = null) {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
@@ -60,6 +71,12 @@ document.addEventListener(
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
|
||||
17
apps/client/src/share/mermaid.ts
Normal file
17
apps/client/src/share/mermaid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mermaid from "mermaid";
|
||||
|
||||
export default function setupMermaid() {
|
||||
for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) {
|
||||
const parentPre = codeBlock.parentElement;
|
||||
if (!parentPre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mermaidDiv = document.createElement("div");
|
||||
mermaidDiv.classList.add("mermaid");
|
||||
mermaidDiv.innerHTML = codeBlock.innerHTML;
|
||||
parentPre.replaceWith(mermaidDiv);
|
||||
}
|
||||
|
||||
mermaid.init();
|
||||
}
|
||||
284
apps/client/src/styles/gallery.css
Normal file
284
apps/client/src/styles/gallery.css
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
528
apps/client/src/styles/photoswipe-mobile-a11y.css
Normal file
528
apps/client/src/styles/photoswipe-mobile-a11y.css
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
253
apps/client/src/stylesheets/media-viewer.css
Normal file
253
apps/client/src/stylesheets/media-viewer.css
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -139,12 +139,6 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
input[type="number"],
|
||||
input[type="checkbox"] {
|
||||
appearance: auto !important;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
@@ -648,6 +642,10 @@ table.promoted-attributes-in-tooltip th {
|
||||
z-index: calc(var(--ck-z-panel) - 1) !important;
|
||||
}
|
||||
|
||||
.tooltip.tooltip-top {
|
||||
z-index: 32767 !important;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
@@ -1786,6 +1784,54 @@ textarea {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
.jump-to-note-dialog .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-icon {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog kbd.command-shortcut {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.empty-table-placeholder {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
@@ -1844,6 +1890,7 @@ body.zen #launcher-container,
|
||||
body.zen #launcher-pane,
|
||||
body.zen #left-pane,
|
||||
body.zen #right-pane,
|
||||
body.zen #mobile-sidebar-wrapper,
|
||||
body.zen .tab-row-container,
|
||||
body.zen .tab-row-widget,
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
|
||||
@@ -1851,7 +1898,8 @@ 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 .floating-buttons-children > *:not(.bx-edit-alt) {
|
||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||
body.zen .action-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1893,14 +1941,20 @@ body.zen .note-title-widget input {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body.zen #detail-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Content renderer */
|
||||
|
||||
footer.file-footer {
|
||||
footer.file-footer,
|
||||
footer.webview-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer.file-footer button {
|
||||
footer.file-footer button,
|
||||
footer.webview-footer button {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
@@ -2201,6 +2255,13 @@ footer.file-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 {
|
||||
|
||||
@@ -458,6 +458,11 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,15 @@ div.tn-tool-dialog {
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
padding: unset !important;
|
||||
padding-bottom: 26px !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 26px 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-footer {
|
||||
padding-top: 26px;
|
||||
}
|
||||
|
||||
/* Search box wrapper */
|
||||
@@ -228,16 +233,16 @@ div.tn-tool-dialog {
|
||||
|
||||
/* Item title link */
|
||||
|
||||
.recent-changes-content ul li .note-title a {
|
||||
.recent-changes-content ul li a {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.recent-changes-content ul li .note-title a:hover {
|
||||
.recent-changes-content ul li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Item title for deleted notes */
|
||||
.recent-changes-content ul li.deleted-note .note-title > .note-title {
|
||||
.recent-changes-content ul li.deleted-note .note-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
|
||||
@@ -1678,4 +1678,42 @@ div.find-replace-widget div.find-widget-found-wrapper > span {
|
||||
#right-pane .highlights-list li:active {
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/** Canvas **/
|
||||
|
||||
.excalidraw {
|
||||
--border-radius-lg: 6px;
|
||||
}
|
||||
|
||||
.excalidraw .Island {
|
||||
backdrop-filter: var(--dropdown-backdrop-filter);
|
||||
}
|
||||
|
||||
.excalidraw .Island.App-toolbar {
|
||||
--island-bg-color: var(--floating-button-background-color);
|
||||
--shadow-island: 1px 1px 1px var(--floating-button-shadow-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu {
|
||||
border: unset !important;
|
||||
box-shadow: unset !important;
|
||||
background-color: transparent !important;
|
||||
--island-bg-color: var(--menu-background-color);
|
||||
--shadow-island: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
|
||||
--default-border-color: var(--bs-dropdown-divider-bg);
|
||||
--button-hover-bg: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container {
|
||||
border-radius: var(--dropdown-border-radius);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container > div:not([class]):not(:last-child) {
|
||||
margin-left: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
margin-right: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu:before {
|
||||
content: unset !important;
|
||||
}
|
||||
185
apps/client/src/translations/ca/translation.json
Normal file
185
apps/client/src/translations/ca/translation.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Sobre Trilium Notes",
|
||||
"homepage": "Pàgina principal:"
|
||||
},
|
||||
"add_link": {
|
||||
"note": "Nota"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Desa"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"labels": "Etiquetes",
|
||||
"relations": "Relacions",
|
||||
"notes": "Notes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmació",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Tanca",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"export": {
|
||||
"close": "Tanca",
|
||||
"export": "Exporta"
|
||||
},
|
||||
"help": {
|
||||
"troubleshooting": "Solució de problemes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"import": {
|
||||
"options": "Opcions",
|
||||
"import": "Importa"
|
||||
},
|
||||
"include_note": {
|
||||
"label_note": "Nota"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Tanca",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"templates": "Plantilles:"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Sol·licitud",
|
||||
"defaultTitle": "Sol·licitud"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Tanca"
|
||||
},
|
||||
"recent_changes": {
|
||||
"undelete_link": "recuperar"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Restaura",
|
||||
"delete_button": "Suprimeix",
|
||||
"download_button": "Descarrega",
|
||||
"mime": "MIME: ",
|
||||
"preview": "Vista prèvia:"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"title": "títol",
|
||||
"ascending": "ascendent",
|
||||
"descending": "descendent",
|
||||
"folders": "Carpetes"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Opcions",
|
||||
"upload": "Puja"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Nom",
|
||||
"value": "Valor",
|
||||
"promoted": "Destacat",
|
||||
"promoted_alias": "Àlies",
|
||||
"multiplicity": "Multiplicitat",
|
||||
"label_type": "Tipus",
|
||||
"text": "Text",
|
||||
"number": "Número",
|
||||
"boolean": "Booleà",
|
||||
"date": "Data",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision": "Precisió",
|
||||
"digits": "dígits",
|
||||
"inheritable": "Heretable",
|
||||
"delete": "Suprimeix",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "Per"
|
||||
},
|
||||
"move_note": {
|
||||
"to": "a"
|
||||
},
|
||||
"add_relation": {
|
||||
"to": "a"
|
||||
},
|
||||
"rename_relation": {
|
||||
"to": "Per"
|
||||
},
|
||||
"update_relation_target": {
|
||||
"to": "a"
|
||||
},
|
||||
"attachments_actions": {
|
||||
"download": "Descarrega"
|
||||
},
|
||||
"calendar": {
|
||||
"mon": "Dl",
|
||||
"tue": "Dt",
|
||||
"wed": "dc",
|
||||
"thu": "Dj",
|
||||
"fri": "Dv",
|
||||
"sat": "Ds",
|
||||
"sun": "Dg",
|
||||
"january": "Gener",
|
||||
"febuary": "Febrer",
|
||||
"march": "Març",
|
||||
"april": "Abril",
|
||||
"may": "Maig",
|
||||
"june": "Juny",
|
||||
"july": "Juliol",
|
||||
"august": "Agost",
|
||||
"september": "Setembre",
|
||||
"october": "Octubre",
|
||||
"november": "Novembre",
|
||||
"december": "Desembre"
|
||||
},
|
||||
"global_menu": {
|
||||
"menu": "Menú",
|
||||
"options": "Opcions",
|
||||
"zoom": "Zoom",
|
||||
"advanced": "Avançat",
|
||||
"logout": "Tanca la sessió"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relació"
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "Categoria:",
|
||||
"search": "Cerca:"
|
||||
},
|
||||
"basic_properties": {
|
||||
"editable": "Editable",
|
||||
"language": "Llengua"
|
||||
},
|
||||
"book_properties": {
|
||||
"grid": "Graella",
|
||||
"list": "Llista",
|
||||
"collapse": "Replega",
|
||||
"expand": "Desplega",
|
||||
"calendar": "Calendari",
|
||||
"table": "Taula",
|
||||
"board": "Tauler"
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(suprimit)"
|
||||
},
|
||||
"file_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Fitxer"
|
||||
},
|
||||
"image_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Imatge"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"created": "Creat",
|
||||
"modified": "Modificat",
|
||||
"type": "Tipus",
|
||||
"calculate": "calcula"
|
||||
},
|
||||
"note_paths": {
|
||||
"archived": "Arxivat"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "关于 Trilium Notes",
|
||||
"close": "关闭",
|
||||
"homepage": "项目主页:",
|
||||
"app_version": "应用版本:",
|
||||
"db_version": "数据库版本:",
|
||||
@@ -28,25 +27,22 @@
|
||||
"add_link": {
|
||||
"add_link": "添加链接",
|
||||
"help_on_links": "链接帮助",
|
||||
"close": "关闭",
|
||||
"note": "笔记",
|
||||
"search_note": "按名称搜索笔记",
|
||||
"link_title_mirrors": "链接标题跟随笔记标题变化",
|
||||
"link_title_arbitrary": "链接标题可随意修改",
|
||||
"link_title": "链接标题",
|
||||
"button_add_link": "添加链接 <kbd>回车</kbd>"
|
||||
"button_add_link": "添加链接"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "编辑分支前缀",
|
||||
"help_on_tree_prefix": "有关树前缀的帮助",
|
||||
"close": "关闭",
|
||||
"prefix": "前缀:",
|
||||
"prefix": "前缀: ",
|
||||
"save": "保存",
|
||||
"branch_prefix_saved": "分支前缀已保存。"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "批量操作",
|
||||
"close": "关闭",
|
||||
"affected_notes": "受影响的笔记",
|
||||
"include_descendants": "包括所选笔记的子笔记",
|
||||
"available_actions": "可用操作",
|
||||
@@ -61,23 +57,21 @@
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "克隆笔记到...",
|
||||
"close": "关闭",
|
||||
"help_on_links": "链接帮助",
|
||||
"notes_to_clone": "要克隆的笔记",
|
||||
"target_parent_note": "目标父笔记",
|
||||
"search_for_note_by_its_name": "按名称搜索笔记",
|
||||
"cloned_note_prefix_title": "克隆的笔记将在笔记树中显示给定的前缀",
|
||||
"prefix_optional": "前缀(可选)",
|
||||
"clone_to_selected_note": "克隆到选定的笔记 <kbd>回车</kbd>",
|
||||
"clone_to_selected_note": "克隆到选定的笔记",
|
||||
"no_path_to_clone_to": "没有克隆路径。",
|
||||
"note_cloned": "笔记 \"{{clonedTitle}}\" 已克隆到 \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "确认",
|
||||
"close": "关闭",
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"are_you_sure_remove_note": "确定要从关系图中移除笔记 \"{{title}}\" ?",
|
||||
"are_you_sure_remove_note": "确定要从关系图中移除笔记 \"{{title}}\" ? ",
|
||||
"if_you_dont_check": "如果不选中此项,笔记将仅从关系图中移除。",
|
||||
"also_delete_note": "同时删除笔记"
|
||||
},
|
||||
@@ -87,9 +81,9 @@
|
||||
"delete_all_clones_description": "同时删除所有克隆(可以在最近修改中撤消)",
|
||||
"erase_notes_description": "通常(软)删除仅标记笔记为已删除,可以在一段时间内通过最近修改对话框撤消。选中此选项将立即擦除笔记,不可撤销。",
|
||||
"erase_notes_warning": "永久擦除笔记(无法撤销),包括所有克隆。这将强制应用程序重载。",
|
||||
"notes_to_be_deleted": "将删除以下笔记 ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "将删除以下笔记 ({{notesCount}})",
|
||||
"no_note_to_delete": "没有笔记将被删除(仅克隆)。",
|
||||
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{ relationCount}})",
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"deleted_relation_text": "笔记 {{- note}} (将被删除的笔记) 被以下关系 {{- relation}} 引用, 来自 {{- source}}。"
|
||||
@@ -113,20 +107,17 @@
|
||||
"format_pdf": "PDF - 用于打印或共享目的。"
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "帮助(完整<a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">在线文档</a>)",
|
||||
"close": "关闭",
|
||||
"noteNavigation": "笔记导航",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - 在笔记列表中向上/向下移动",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - 折叠/展开节点",
|
||||
"goUpDown": "在笔记列表中向上/向下移动",
|
||||
"collapseExpand": "折叠/展开节点",
|
||||
"notSet": "未设置",
|
||||
"goBackForwards": "在历史记录中前后移动",
|
||||
"showJumpToNoteDialog": "显示<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"跳转到\" 对话框</a>",
|
||||
"scrollToActiveNote": "滚动到活跃笔记",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - 跳转到父笔记",
|
||||
"jumpToParentNote": "跳转到父笔记",
|
||||
"collapseWholeTree": "折叠整个笔记树",
|
||||
"collapseSubTree": "折叠子树",
|
||||
"tabShortcuts": "标签页快捷键",
|
||||
"newTabNoteLink": "<kbd>CTRL+click</kbd> - 在笔记链接上使用CTRL+点击(或中键点击)在新标签页中打开笔记",
|
||||
"onlyInDesktop": "仅在桌面版(电子构建)中",
|
||||
"openEmptyTab": "打开空白标签页",
|
||||
"closeActiveTab": "关闭活跃标签页",
|
||||
@@ -141,14 +132,14 @@
|
||||
"moveNoteUpHierarchy": "在层级结构中向上移动笔记",
|
||||
"multiSelectNote": "多选上/下笔记",
|
||||
"selectAllNotes": "选择当前级别的所有笔记",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - 选择笔记",
|
||||
"selectNote": "选择笔记",
|
||||
"copyNotes": "将活跃笔记(或当前选择)复制到剪贴板(用于<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">克隆</a>)",
|
||||
"cutNotes": "将当前笔记(或当前选择)剪切到剪贴板(用于移动笔记)",
|
||||
"pasteNotes": "将笔记粘贴为活跃笔记的子笔记(根据是复制还是剪切到剪贴板来决定是移动还是克隆)",
|
||||
"deleteNotes": "删除笔记/子树",
|
||||
"editingNotes": "编辑笔记",
|
||||
"editNoteTitle": "在树形笔记树中,焦点会从笔记树切换到笔记标题。按下 Enter 键会将焦点从笔记标题切换到文本编辑器。按下 <kbd>Ctrl+.</kbd> 会将焦点从编辑器切换回笔记树。",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - 创建/编辑外部链接",
|
||||
"createEditLink": "创建/编辑外部链接",
|
||||
"createInternalLink": "创建内部链接",
|
||||
"followLink": "跟随光标下的链接",
|
||||
"insertDateTime": "在插入点插入当前日期和时间",
|
||||
@@ -164,11 +155,13 @@
|
||||
"showSQLConsole": "显示 SQL 控制台",
|
||||
"other": "其他",
|
||||
"quickSearch": "定位到快速搜索框",
|
||||
"inPageSearch": "页面内搜索"
|
||||
"inPageSearch": "页面内搜索",
|
||||
"newTabWithActivationNoteLink": "在新标签页打开笔记链接并激活该标签页",
|
||||
"title": "资料表",
|
||||
"newTabNoteLink": "在新标签页开启链接"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "导入到笔记",
|
||||
"close": "关闭",
|
||||
"chooseImportFile": "选择导入文件",
|
||||
"importDescription": "所选文件的内容将作为子笔记导入到",
|
||||
"options": "选项",
|
||||
@@ -195,14 +188,13 @@
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "包含笔记",
|
||||
"close": "关闭",
|
||||
"label_note": "笔记",
|
||||
"placeholder_search": "按名称搜索笔记",
|
||||
"box_size_prompt": "包含笔记的框大小:",
|
||||
"box_size_small": "小型 (显示大约10行)",
|
||||
"box_size_medium": "中型 (显示大约30行)",
|
||||
"box_size_full": "完整显示(完整文本框)",
|
||||
"button_include": "包含笔记 <kbd>回车</kbd>"
|
||||
"button_include": "包含笔记"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "信息消息",
|
||||
@@ -210,43 +202,41 @@
|
||||
"okButton": "确定"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "按笔记名称搜索",
|
||||
"close": "关闭",
|
||||
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>"
|
||||
"search_button": "全文搜索",
|
||||
"search_placeholder": "按名称或类型搜索笔记 > 查看命令..."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown 导入",
|
||||
"close": "关闭",
|
||||
"modal_body_text": "由于浏览器沙箱的限制,无法直接从 JavaScript 读取剪贴板内容。请将要导入的 Markdown 文本粘贴到下面的文本框中,然后点击导入按钮",
|
||||
"import_button": "导入 Ctrl+回车",
|
||||
"import_button": "导入",
|
||||
"import_success": "Markdown 内容已成功导入文档。"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "移动笔记到...",
|
||||
"close": "关闭",
|
||||
"notes_to_move": "需要移动的笔记",
|
||||
"target_parent_note": "目标父笔记",
|
||||
"search_placeholder": "通过名称搜索笔记",
|
||||
"move_button": "移动到选定的笔记 <kbd>回车</kbd>",
|
||||
"move_button": "移动到选定的笔记",
|
||||
"error_no_path": "没有可以移动到的路径。",
|
||||
"move_success_message": "所选笔记已移动到"
|
||||
"move_success_message": "所选笔记已移动到 "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"modal_title": "选择笔记类型",
|
||||
"close": "关闭",
|
||||
"modal_body": "选择新笔记的类型或模板:",
|
||||
"templates": "模板:"
|
||||
"templates": "模板",
|
||||
"change_path_prompt": "更改创建新笔记的位置:",
|
||||
"search_placeholder": "按名称搜索路径(默认为空)",
|
||||
"builtin_templates": "内置模板"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密码未设置",
|
||||
"close": "关闭",
|
||||
"body1": "受保护的笔记使用用户密码加密,但密码尚未设置。",
|
||||
"body2": "点击<a class=\"open-password-options-button\" href=\"javascript:\">这里</a>打开选项对话框并设置您的密码。"
|
||||
"body2": "若要保护笔记,请按一下下方按钮开启「选项」对话框并设置密码。",
|
||||
"go_to_password_options": "移动至密码选项"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "提示",
|
||||
"close": "关闭",
|
||||
"ok": "确定 <kbd>回车</kbd>",
|
||||
"ok": "确定",
|
||||
"defaultTitle": "提示"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -254,12 +244,11 @@
|
||||
"help_title": "关于保护笔记的帮助",
|
||||
"close_label": "关闭",
|
||||
"form_label": "输入密码进入保护会话以继续:",
|
||||
"start_button": "开始保护会话 <kbd>回车</kbd>"
|
||||
"start_button": "开始保护会话"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "最近修改",
|
||||
"erase_notes_button": "立即清理已删除的笔记",
|
||||
"close": "关闭",
|
||||
"deleted_notes_message": "已删除的笔记已清理。",
|
||||
"no_changes_message": "暂无修改...",
|
||||
"undelete_link": "恢复删除",
|
||||
@@ -270,7 +259,6 @@
|
||||
"delete_all_revisions": "删除此笔记的所有修订版本",
|
||||
"delete_all_button": "删除所有修订版本",
|
||||
"help_title": "关于笔记修订版本的帮助",
|
||||
"close": "关闭",
|
||||
"revision_last_edited": "此修订版本上次编辑于 {{date}}",
|
||||
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
|
||||
"no_revisions": "此笔记暂无修订版本...",
|
||||
@@ -285,14 +273,13 @@
|
||||
"maximum_revisions": "当前笔记的最大历史数量: {{number}}。",
|
||||
"settings": "笔记修订设置",
|
||||
"download_button": "下载",
|
||||
"mime": "MIME 类型:",
|
||||
"mime": "MIME 类型: ",
|
||||
"file_size": "文件大小:",
|
||||
"preview": "预览:",
|
||||
"preview_not_available": "无法预览此类型的笔记。"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "按...排序子笔记",
|
||||
"close": "关闭",
|
||||
"sorting_criteria": "排序条件",
|
||||
"title": "标题",
|
||||
"date_created": "创建日期",
|
||||
@@ -306,13 +293,12 @@
|
||||
"sort_with_respect_to_different_character_sorting": "根据不同语言或地区的字符排序和排序规则排序。",
|
||||
"natural_sort_language": "自然排序语言",
|
||||
"the_language_code_for_natural_sort": "自然排序的语言代码,例如中文的 \"zh-CN\"。",
|
||||
"sort": "排序 <kbd>Enter</kbd>"
|
||||
"sort": "排序"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "上传附件到笔记",
|
||||
"close": "关闭",
|
||||
"choose_files": "选择文件",
|
||||
"files_will_be_uploaded": "文件将作为附件上传到",
|
||||
"files_will_be_uploaded": "文件将作为附件上传到 {{noteTitle}}",
|
||||
"options": "选项",
|
||||
"shrink_images": "缩小图片",
|
||||
"upload": "上传",
|
||||
@@ -427,7 +413,7 @@
|
||||
"run_on_branch_change": "在分支更新时执行。",
|
||||
"run_on_branch_deletion": "在删除分支时执行。分支是父笔记和子笔记之间的链接,例如在移动笔记时删除(删除旧的分支/链接)。",
|
||||
"run_on_attribute_creation": "在为定义此关系的笔记创建新属性时执行",
|
||||
"run_on_attribute_change": "当修改定义此关系的笔记的属性时执行。删除属性时也会触发此操作。",
|
||||
"run_on_attribute_change": " 当修改定义此关系的笔记的属性时执行。删除属性时也会触发此操作。",
|
||||
"relation_template": "即使没有父子关系,笔记的属性也将继承。如果空,则笔记的内容和子树将添加到实例笔记中。有关详细信息,请参见文档。",
|
||||
"inherit": "即使没有父子关系,笔记的属性也将继承。有关类似概念的模板关系,请参见模板关系。请参阅文档中的属性继承。",
|
||||
"render_note": "“渲染 HTML 笔记”类型的笔记将使用代码笔记(HTML 或脚本)进行呈现,因此需要指定要渲染的笔记",
|
||||
@@ -438,14 +424,15 @@
|
||||
"share_favicon": "在分享页面中设置的 favicon 笔记。一般需要将它设置为分享和可继承。Favicon 笔记也必须位于分享子树中。可以考虑一并使用 'share_hidden_from_tree'。",
|
||||
"is_owned_by_note": "由此笔记所有",
|
||||
"other_notes_with_name": "其它含有 {{attributeType}} 名为 \"{{attributeName}}\" 的的笔记",
|
||||
"and_more": "... 以及另外 {{count}} 个",
|
||||
"and_more": "... 以及另外 {{count}} 个。",
|
||||
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
|
||||
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。"
|
||||
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
|
||||
"color_type": "颜色"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
|
||||
"help_text_body2": "对于关系,请输入 <code>~author = @</code>,这将显示一个自动完成列表,您可以查找所需的笔记。",
|
||||
"help_text_body3": "您也可以使用右侧的 <code>+</code> 按钮添加标签和关系。</p>",
|
||||
"help_text_body3": "您也可以使用右侧的 <code>+</code> 按钮添加标签和关系。",
|
||||
"save_attributes": "保存属性 <enter>",
|
||||
"add_a_new_attribute": "添加新属性",
|
||||
"add_new_label": "添加新标签 <kbd data-command=\"addNewLabel\"></kbd>",
|
||||
@@ -744,7 +731,8 @@
|
||||
"basic_properties": {
|
||||
"note_type": "笔记类型",
|
||||
"editable": "可编辑",
|
||||
"basic_properties": "基本属性"
|
||||
"basic_properties": "基本属性",
|
||||
"language": "语言"
|
||||
},
|
||||
"book_properties": {
|
||||
"view_type": "视图类型",
|
||||
@@ -754,9 +742,12 @@
|
||||
"expand_all_children": "展开所有子项",
|
||||
"collapse": "折叠",
|
||||
"expand": "展开",
|
||||
"book_properties": "",
|
||||
"invalid_view_type": "无效的查看类型 '{{type}}'",
|
||||
"calendar": "日历"
|
||||
"calendar": "日历",
|
||||
"book_properties": "集合属性",
|
||||
"table": "表格",
|
||||
"geo-map": "地理地图",
|
||||
"board": "看板"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "今天还没有编辑过的笔记...",
|
||||
@@ -833,7 +824,8 @@
|
||||
"unknown_label_type": "未知的标签类型 '{{type}}'",
|
||||
"unknown_attribute_type": "未知的属性类型 '{{type}}'",
|
||||
"add_new_attribute": "添加新属性",
|
||||
"remove_this_attribute": "移除此属性"
|
||||
"remove_this_attribute": "移除此属性",
|
||||
"remove_color": "移除此颜色标签"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "查询",
|
||||
@@ -944,19 +936,19 @@
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "打开附件帮助页面",
|
||||
"owning_note": "所属笔记:",
|
||||
"you_can_also_open": ",您还可以打开",
|
||||
"owning_note": "所属笔记: ",
|
||||
"you_can_also_open": ",您还可以打开 ",
|
||||
"list_of_all_attachments": "所有附件列表",
|
||||
"attachment_deleted": "该附件已被删除。"
|
||||
},
|
||||
"attachment_list": {
|
||||
"open_help_page": "打开附件帮助页面",
|
||||
"owning_note": "所属笔记:",
|
||||
"owning_note": "所属笔记: ",
|
||||
"upload_attachments": "上传附件",
|
||||
"no_attachments": "此笔记没有附件。"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "此类型为书籍的笔记没有任何子笔记,因此没有内容显示。请参阅 <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> 了解详情"
|
||||
"no_children_help": "此类型为书籍的笔记没有任何子笔记,因此没有内容显示。请参阅 <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> 了解详情。"
|
||||
},
|
||||
"editable_code": {
|
||||
"placeholder": "在这里输入您的代码笔记内容..."
|
||||
@@ -1092,7 +1084,8 @@
|
||||
"max_width_label": "内容最大宽度(像素)",
|
||||
"apply_changes_description": "要应用内容宽度更改,请点击",
|
||||
"reload_button": "重载前端",
|
||||
"reload_description": "来自外观选项的更改"
|
||||
"reload_description": "来自外观选项的更改",
|
||||
"max_width_unit": "像素"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "原生标题栏(需要重新启动应用)",
|
||||
@@ -1108,17 +1101,17 @@
|
||||
"title": "主题",
|
||||
"theme_label": "主题",
|
||||
"override_theme_fonts_label": "覆盖主题字体",
|
||||
"auto_theme": "自动",
|
||||
"light_theme": "浅色",
|
||||
"dark_theme": "深色",
|
||||
"triliumnext": "TriliumNext Beta(跟随系统颜色方案)",
|
||||
"triliumnext-light": "TriliumNext Beta(浅色)",
|
||||
"triliumnext-dark": "TriliumNext Beta(深色)",
|
||||
"triliumnext": "Trilium(跟随系统颜色方案)",
|
||||
"triliumnext-light": "Trilium(浅色)",
|
||||
"triliumnext-dark": "Trilium(深色)",
|
||||
"layout": "布局",
|
||||
"layout-vertical-title": "垂直",
|
||||
"layout-horizontal-title": "水平",
|
||||
"layout-vertical-description": "启动栏位于左侧(默认)",
|
||||
"layout-horizontal-description": "启动栏位于标签页栏下方,标签页栏现在是全宽的。"
|
||||
"layout-horizontal-description": "启动栏位于标签页栏下方,标签页栏现在是全宽的。",
|
||||
"auto_theme": "传统(跟随系统配色方案)",
|
||||
"light_theme": "传统(浅色)",
|
||||
"dark_theme": "传统(深色)"
|
||||
},
|
||||
"zoom_factor": {
|
||||
"title": "缩放系数(仅桌面客户端有效)",
|
||||
@@ -1127,7 +1120,8 @@
|
||||
"code_auto_read_only_size": {
|
||||
"title": "自动只读大小",
|
||||
"description": "自动只读大小是指笔记超过设置的大小后自动设置为只读模式(为性能考虑)。",
|
||||
"label": "自动只读大小(代码笔记)"
|
||||
"label": "自动只读大小(代码笔记)",
|
||||
"unit": "字符"
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "下拉菜单可用的MIME文件类型"
|
||||
@@ -1146,7 +1140,8 @@
|
||||
"download_images_description": "粘贴的 HTML 可能包含在线图片的引用,Trilium 会找到这些引用并下载图片,以便它们可以离线使用。",
|
||||
"enable_image_compression": "启用图片压缩",
|
||||
"max_image_dimensions": "图片的最大宽度/高度(超过此限制的图像将会被缩放)。",
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)"
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)",
|
||||
"max_image_dimensions_unit": "像素"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "附件清理超时",
|
||||
@@ -1178,7 +1173,8 @@
|
||||
"note_revisions_snapshot_limit_description": "笔记修订快照数限制指的是每个笔记可以保存的最大历史记录数量。其中 -1 表示没有限制,0 表示删除所有历史记录。您可以通过 #versioningLimit 标签设置单个笔记的最大修订记录数量。",
|
||||
"snapshot_number_limit_label": "笔记修订快照数量限制:",
|
||||
"erase_excess_revision_snapshots": "立即删除多余的修订快照",
|
||||
"erase_excess_revision_snapshots_prompt": "多余的修订快照已被删除。"
|
||||
"erase_excess_revision_snapshots_prompt": "多余的修订快照已被删除。",
|
||||
"snapshot_number_limit_unit": "快照"
|
||||
},
|
||||
"search_engine": {
|
||||
"title": "搜索引擎",
|
||||
@@ -1220,12 +1216,14 @@
|
||||
"title": "目录",
|
||||
"description": "当笔记中有超过一定数量的标题时,显示目录。您可以自定义此数量:",
|
||||
"disable_info": "您可以设置一个非常大的数来禁用目录。",
|
||||
"shortcut_info": "您可以在 “选项” -> “快捷键” 中配置一个键盘快捷键,以便快速切换右侧面板(包括目录)(名称为 'toggleRightPane')。"
|
||||
"shortcut_info": "您可以在 “选项” -> “快捷键” 中配置一个键盘快捷键,以便快速切换右侧面板(包括目录)(名称为 'toggleRightPane')。",
|
||||
"unit": "标题"
|
||||
},
|
||||
"text_auto_read_only_size": {
|
||||
"title": "自动只读大小",
|
||||
"description": "自动只读笔记大小是超过该大小后,笔记将以只读模式显示(出于性能考虑)。",
|
||||
"label": "自动只读大小(文本笔记)"
|
||||
"label": "自动只读大小(文本笔记)",
|
||||
"unit": "字符"
|
||||
},
|
||||
"i18n": {
|
||||
"title": "本地化",
|
||||
@@ -1376,7 +1374,8 @@
|
||||
"test_title": "同步测试",
|
||||
"test_description": "测试和同步服务器之间的连接。如果同步服务器没有初始化,会将本地文档同步到同步服务器上。",
|
||||
"test_button": "测试同步",
|
||||
"handshake_failed": "同步服务器握手失败,错误:{{message}}"
|
||||
"handshake_failed": "同步服务器握手失败,错误:{{message}}",
|
||||
"timeout_unit": "毫秒"
|
||||
},
|
||||
"api_log": {
|
||||
"close": "关闭"
|
||||
@@ -1435,7 +1434,9 @@
|
||||
"import-into-note": "导入到笔记",
|
||||
"apply-bulk-actions": "应用批量操作",
|
||||
"converted-to-attachments": "{{count}} 个笔记已被转换为附件。",
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?"
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?",
|
||||
"duplicate": "复制",
|
||||
"open-in-popup": "快速编辑"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "此笔记已公开分享于",
|
||||
@@ -1449,7 +1450,6 @@
|
||||
"relation-map": "关系图",
|
||||
"note-map": "笔记地图",
|
||||
"render-note": "渲染笔记",
|
||||
"book": "书",
|
||||
"mermaid-diagram": "Mermaid 图",
|
||||
"canvas": "画布",
|
||||
"web-view": "网页视图",
|
||||
@@ -1462,7 +1462,11 @@
|
||||
"confirm-change": "当笔记内容不为空时,不建议更改笔记类型。您仍然要继续吗?",
|
||||
"geo-map": "地理地图",
|
||||
"beta-feature": "测试版",
|
||||
"task-list": "待办事项列表"
|
||||
"task-list": "任务列表",
|
||||
"ai-chat": "AI聊天",
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
@@ -1572,7 +1576,9 @@
|
||||
},
|
||||
"clipboard": {
|
||||
"cut": "笔记已剪切到剪贴板。",
|
||||
"copied": "笔记已复制到剪贴板。"
|
||||
"copied": "笔记已复制到剪贴板。",
|
||||
"copy_failed": "由于权限问题,无法复制到剪贴板。",
|
||||
"copy_success": "已复制到剪贴板。"
|
||||
},
|
||||
"entrypoints": {
|
||||
"note-revision-created": "笔记修订已创建。",
|
||||
@@ -1623,7 +1629,8 @@
|
||||
"word_wrapping": "自动换行",
|
||||
"theme_none": "无语法高亮",
|
||||
"theme_group_light": "浅色主题",
|
||||
"theme_group_dark": "深色主题"
|
||||
"theme_group_dark": "深色主题",
|
||||
"copy_title": "复制到剪贴板"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "格式"
|
||||
@@ -1661,7 +1668,8 @@
|
||||
"link_context_menu": {
|
||||
"open_note_in_new_tab": "在新标签页中打开笔记",
|
||||
"open_note_in_new_split": "在新分屏中打开笔记",
|
||||
"open_note_in_new_window": "在新窗口中打开笔记"
|
||||
"open_note_in_new_window": "在新窗口中打开笔记",
|
||||
"open_note_in_popup": "快速编辑"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "桌面应用程序",
|
||||
@@ -1681,7 +1689,8 @@
|
||||
"full-text-search": "全文搜索"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "笔记已被删除。"
|
||||
"note-has-been-deleted": "笔记已被删除。",
|
||||
"quick-edit": "快速编辑"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "创建一个新的子笔记并将其添加到地图中",
|
||||
@@ -1690,7 +1699,8 @@
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "打开位置",
|
||||
"remove-from-map": "从地图中移除"
|
||||
"remove-from-map": "从地图中移除",
|
||||
"add-note": "在这个位置添加一个标记"
|
||||
},
|
||||
"help-button": {
|
||||
"title": "打开相关帮助页面"
|
||||
@@ -1722,5 +1732,271 @@
|
||||
"tomorrow": "明天",
|
||||
"yesterday": "昨天"
|
||||
}
|
||||
},
|
||||
"ai_llm": {
|
||||
"not_started": "未开始",
|
||||
"title": "AI设置",
|
||||
"processed_notes": "已处理笔记",
|
||||
"total_notes": "笔记总数",
|
||||
"progress": "进度",
|
||||
"queued_notes": "排队中笔记",
|
||||
"failed_notes": "失败笔记",
|
||||
"last_processed": "最后处理时间",
|
||||
"refresh_stats": "刷新统计数据",
|
||||
"enable_ai_features": "启用AI/LLM功能",
|
||||
"enable_ai_description": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
|
||||
"openai_tab": "OpenAI",
|
||||
"anthropic_tab": "Anthropic",
|
||||
"voyage_tab": "Voyage AI",
|
||||
"ollama_tab": "Ollama",
|
||||
"enable_ai": "启用AI/LLM功能",
|
||||
"enable_ai_desc": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
|
||||
"provider_configuration": "AI提供商配置",
|
||||
"provider_precedence": "提供商优先级",
|
||||
"provider_precedence_description": "按优先级排序的提供商列表(用逗号分隔,例如:'openai,anthropic,ollama')",
|
||||
"temperature": "温度参数",
|
||||
"temperature_description": "控制响应的随机性(0 = 确定性输出,2 = 最大随机性)",
|
||||
"system_prompt": "系统提示词",
|
||||
"system_prompt_description": "所有AI交互使用的默认系统提示词",
|
||||
"openai_configuration": "OpenAI配置",
|
||||
"openai_settings": "OpenAI设置",
|
||||
"api_key": "API密钥",
|
||||
"url": "基础URL",
|
||||
"model": "模型",
|
||||
"openai_api_key_description": "用于访问OpenAI服务的API密钥",
|
||||
"anthropic_api_key_description": "用于访问Claude模型的Anthropic API密钥",
|
||||
"default_model": "默认模型",
|
||||
"openai_model_description": "示例:gpt-4o、gpt-4-turbo、gpt-3.5-turbo",
|
||||
"base_url": "基础URL",
|
||||
"openai_url_description": "默认:https://api.openai.com/v1",
|
||||
"anthropic_settings": "Anthropic设置",
|
||||
"anthropic_url_description": "Anthropic API的基础URL(默认:https://api.anthropic.com)",
|
||||
"anthropic_model_description": "用于聊天补全的Anthropic Claude模型",
|
||||
"voyage_settings": "Voyage AI设置",
|
||||
"ollama_settings": "Ollama设置",
|
||||
"ollama_url_description": "Ollama API的URL(默认:http://localhost:11434)",
|
||||
"ollama_model_description": "用于聊天补全的 Ollama 模型",
|
||||
"anthropic_configuration": "Anthropic配置",
|
||||
"voyage_configuration": "Voyage AI配置",
|
||||
"voyage_url_description": "默认:https://api.voyageai.com/v1",
|
||||
"ollama_configuration": "Ollama配置",
|
||||
"enable_ollama": "启用Ollama",
|
||||
"enable_ollama_description": "启用Ollama以使用本地AI模型",
|
||||
"ollama_url": "Ollama URL",
|
||||
"ollama_model": "Ollama模型",
|
||||
"refresh_models": "刷新模型",
|
||||
"refreshing_models": "刷新中...",
|
||||
"enable_automatic_indexing": "启用自动索引",
|
||||
"rebuild_index": "重建索引",
|
||||
"rebuild_index_error": "启动索引重建失败。请查看日志了解详情。",
|
||||
"note_title": "笔记标题",
|
||||
"error": "错误",
|
||||
"last_attempt": "最后尝试时间",
|
||||
"actions": "操作",
|
||||
"retry": "重试",
|
||||
"partial": "{{ percentage }}% 已完成",
|
||||
"retry_queued": "笔记已加入重试队列",
|
||||
"retry_failed": "笔记加入重试队列失败",
|
||||
"max_notes_per_llm_query": "每次查询的最大笔记数",
|
||||
"max_notes_per_llm_query_description": "AI上下文包含的最大相似笔记数量",
|
||||
"active_providers": "活跃提供商",
|
||||
"disabled_providers": "已禁用提供商",
|
||||
"remove_provider": "从搜索中移除提供商",
|
||||
"restore_provider": "将提供商恢复到搜索中",
|
||||
"similarity_threshold": "相似度阈值",
|
||||
"similarity_threshold_description": "纳入LLM查询上下文的笔记最低相似度分数(0-1)",
|
||||
"reprocess_index": "重建搜索索引",
|
||||
"reprocessing_index": "重建中...",
|
||||
"reprocess_index_started": "搜索索引优化已在后台启动",
|
||||
"reprocess_index_error": "重建搜索索引失败",
|
||||
"index_rebuild_progress": "索引重建进度",
|
||||
"index_rebuilding": "正在优化索引({{percentage}}%)",
|
||||
"index_rebuild_complete": "索引优化完成",
|
||||
"index_rebuild_status_error": "检查索引重建状态失败",
|
||||
"never": "从未",
|
||||
"processing": "处理中({{percentage}}%)",
|
||||
"incomplete": "未完成({{percentage}}%)",
|
||||
"complete": "已完成(100%)",
|
||||
"refreshing": "刷新中...",
|
||||
"auto_refresh_notice": "每 {{seconds}} 秒自动刷新",
|
||||
"note_queued_for_retry": "笔记已加入重试队列",
|
||||
"failed_to_retry_note": "重试笔记失败",
|
||||
"all_notes_queued_for_retry": "所有失败笔记已加入重试队列",
|
||||
"failed_to_retry_all": "重试笔记失败",
|
||||
"ai_settings": "AI设置",
|
||||
"api_key_tooltip": "用于访问服务的API密钥",
|
||||
"empty_key_warning": {
|
||||
"anthropic": "Anthropic API密钥为空。请输入有效的API密钥。",
|
||||
"openai": "OpenAI API密钥为空。请输入有效的API密钥。",
|
||||
"voyage": "Voyage API密钥为空。请输入有效的API密钥。",
|
||||
"ollama": "Ollama API密钥为空。请输入有效的API密钥。"
|
||||
},
|
||||
"agent": {
|
||||
"processing": "处理中...",
|
||||
"thinking": "思考中...",
|
||||
"loading": "加载中...",
|
||||
"generating": "生成中..."
|
||||
},
|
||||
"name": "AI",
|
||||
"openai": "OpenAI",
|
||||
"use_enhanced_context": "使用增强上下文",
|
||||
"enhanced_context_description": "为AI提供来自笔记及其相关笔记的更多上下文,以获得更好的响应",
|
||||
"show_thinking": "显示思考过程",
|
||||
"show_thinking_description": "显示AI的思维链过程",
|
||||
"enter_message": "输入你的消息...",
|
||||
"error_contacting_provider": "联系AI提供商失败。请检查你的设置和网络连接。",
|
||||
"error_generating_response": "生成AI响应失败",
|
||||
"index_all_notes": "为所有笔记建立索引",
|
||||
"index_status": "索引状态",
|
||||
"indexed_notes": "已索引笔记",
|
||||
"indexing_stopped": "索引已停止",
|
||||
"indexing_in_progress": "索引进行中...",
|
||||
"last_indexed": "最后索引时间",
|
||||
"n_notes_queued_0": "{{ count }} 条笔记已加入索引队列",
|
||||
"note_chat": "笔记聊天",
|
||||
"notes_indexed_0": "{{ count }} 条笔记已索引",
|
||||
"sources": "来源",
|
||||
"start_indexing": "开始索引",
|
||||
"use_advanced_context": "使用高级上下文",
|
||||
"ollama_no_url": "Ollama 未配置。请输入有效的URL。",
|
||||
"chat": {
|
||||
"root_note_title": "AI聊天记录",
|
||||
"root_note_content": "此笔记包含你保存的AI聊天对话。",
|
||||
"new_chat_title": "新聊天",
|
||||
"create_new_ai_chat": "创建新的AI聊天"
|
||||
},
|
||||
"create_new_ai_chat": "创建新的AI聊天",
|
||||
"configuration_warnings": "你的AI配置存在一些问题。请检查你的设置。",
|
||||
"experimental_warning": "LLM功能目前处于实验阶段 - 特此提醒。",
|
||||
"selected_provider": "已选提供商",
|
||||
"selected_provider_description": "选择用于聊天和补全功能的AI提供商",
|
||||
"select_model": "选择模型...",
|
||||
"select_provider": "选择提供商..."
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "编辑器"
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
"content_widget": {
|
||||
"unknown_widget": "未知组件:\"{{id}}\"."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "不设置",
|
||||
"configure-languages": "设置语言..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "内容语言",
|
||||
"description": "选择一种或多种语言出现在只读或可编辑文本注释的基本属性,这将支持拼写检查或从右向左之类的功能。"
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "将编辑面板移至底部",
|
||||
"title_horizontal": "将编辑面板移至左侧"
|
||||
},
|
||||
"toggle_read_only_button": {
|
||||
"unlock-editing": "解锁编辑",
|
||||
"lock-editing": "锁定编辑"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "将图表导出为PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "无法将图表导出为PNG。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外观",
|
||||
"word_wrapping": "自动换行",
|
||||
"color-scheme": "配色方案"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "请下载ARM64版本",
|
||||
"message_macos": "TriliumNext当前正在通过Rosetta 2转译运行,这意味着您在Apple Silicon芯片的Mac上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"message_windows": "TriliumNext当前正在模拟环境中运行,这意味着您在ARM架构的Windows设备上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"recommendation": "为获得最佳体验,请从我们的发布页面下载TriliumNext的原生ARM64版本。",
|
||||
"download_link": "下载原生版本",
|
||||
"continue_anyway": "仍然继续",
|
||||
"dont_show_again": "不再显示此警告"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "功能",
|
||||
"emoji_completion_enabled": "启用表情自动补全",
|
||||
"note_completion_enabled": "启用笔记自动补全"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "新增行",
|
||||
"new-column": "新增列",
|
||||
"sort-column-by": "按\"{{title}}\"排序",
|
||||
"sort-column-ascending": "升序",
|
||||
"sort-column-descending": "降序",
|
||||
"sort-column-clear": "清除排序",
|
||||
"hide-column": "隐藏\"{{title}}\"列",
|
||||
"show-hide-columns": "显示/隐藏列",
|
||||
"row-insert-above": "在上方插入行",
|
||||
"row-insert-below": "在下方插入行",
|
||||
"row-insert-child": "插入子笔记",
|
||||
"add-column-to-the-left": "在左侧添加列",
|
||||
"add-column-to-the-right": "在右侧添加列",
|
||||
"edit-column": "编辑列",
|
||||
"delete_column_confirmation": "确定要删除此列吗?所有笔记中对应的属性都将被移除。",
|
||||
"delete-column": "删除列",
|
||||
"new-column-label": "标签",
|
||||
"new-column-relation": "关联"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "隐藏周末",
|
||||
"display-week-numbers": "显示周数",
|
||||
"map-style": "地图样式:",
|
||||
"max-nesting-depth": "最大嵌套深度:",
|
||||
"raster": "栅格",
|
||||
"vector_light": "矢量(浅色)",
|
||||
"vector_dark": "矢量(深色)",
|
||||
"show-scale": "显示比例尺"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "删除行"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "删除笔记",
|
||||
"move-to": "移动到",
|
||||
"insert-above": "在上方插入",
|
||||
"insert-below": "在下方插入",
|
||||
"delete-column": "删除列",
|
||||
"delete-column-confirmation": "确定要删除此列吗?此列下所有笔记中对应的属性也将被删除。",
|
||||
"new-item": "新增项目",
|
||||
"add-column": "添加列"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "树形:{{name}}",
|
||||
"export_note_title": "导出笔记",
|
||||
"export_note_description": "导出当前笔记",
|
||||
"show_attachments_title": "显示附件",
|
||||
"show_attachments_description": "查看笔记附件",
|
||||
"search_notes_title": "搜索笔记",
|
||||
"search_notes_description": "打开高级搜索",
|
||||
"search_subtree_title": "在子树中搜索",
|
||||
"search_subtree_description": "在当前子树范围内搜索",
|
||||
"search_history_title": "显示搜索历史",
|
||||
"search_history_description": "查看之前的搜索记录",
|
||||
"configure_launch_bar_title": "配置启动栏",
|
||||
"configure_launch_bar_description": "打开启动栏配置,添加或移除项目。"
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "在外部打开"
|
||||
},
|
||||
"modal": {
|
||||
"close": "关闭",
|
||||
"help_title": "显示关于此画面的更多信息"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "新的 Trilium 主题已进入稳定版",
|
||||
"next_theme_message": "有一段时间,我们一直在设计新的主题,为了让应用程序看起来更加现代。",
|
||||
"next_theme_button": "切换至新的 Trilium 主题",
|
||||
"background_effects_title": "背景效果现已推出稳定版本",
|
||||
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
||||
"background_effects_button": "启用背景效果"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
18
apps/client/src/translations/el/translation.json
Normal file
18
apps/client/src/translations/el/translation.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Πληροφορίες για το Trilium Notes",
|
||||
"homepage": "Αρχική Σελίδα:",
|
||||
"app_version": "Έκδοση εφαρμογής:",
|
||||
"db_version": "Έκδοση βάσης δεδομένων:",
|
||||
"sync_version": "Έκδοση πρωτοκόλου συγχρονισμού:",
|
||||
"build_date": "Ημερομηνία χτισίματος εφαρμογής:",
|
||||
"build_revision": "Αριθμός αναθεώρησης χτισίματος:",
|
||||
"data_directory": "Φάκελος δεδομένων:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Κρίσιμο σφάλμα",
|
||||
"message": "Συνέβη κάποιο κρίσιμο σφάλμα, το οποίο δεν επιτρέπει στην εφαρμογή χρήστη να ξεκινήσει:\n\n{{message}}\n\nΤο πιθανότερο είναι να προκλήθηκε από κάποιο script που απέτυχε απρόοπτα. Δοκιμάστε να ξεκινήσετε την εφαρμογή σε ασφαλή λειτουργία για να λύσετε το πρόβλημα."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "About Trilium Notes",
|
||||
"close": "Close",
|
||||
"homepage": "Homepage:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:",
|
||||
@@ -28,25 +27,22 @@
|
||||
"add_link": {
|
||||
"add_link": "Add link",
|
||||
"help_on_links": "Help on links",
|
||||
"close": "Close",
|
||||
"note": "Note",
|
||||
"search_note": "search for note by its name",
|
||||
"link_title_mirrors": "link title mirrors the note's current title",
|
||||
"link_title_arbitrary": "link title can be changed arbitrarily",
|
||||
"link_title": "Link title",
|
||||
"button_add_link": "Add link <kbd>enter</kbd>"
|
||||
"button_add_link": "Add link"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Edit branch prefix",
|
||||
"help_on_tree_prefix": "Help on Tree prefix",
|
||||
"close": "Close",
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Save",
|
||||
"branch_prefix_saved": "Branch prefix has been saved."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Bulk actions",
|
||||
"close": "Close",
|
||||
"affected_notes": "Affected notes",
|
||||
"include_descendants": "Include descendants of the selected notes",
|
||||
"available_actions": "Available actions",
|
||||
@@ -61,20 +57,18 @@
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clone notes to...",
|
||||
"close": "Close",
|
||||
"help_on_links": "Help on links",
|
||||
"notes_to_clone": "Notes to clone",
|
||||
"target_parent_note": "Target parent note",
|
||||
"search_for_note_by_its_name": "search for note by its name",
|
||||
"cloned_note_prefix_title": "Cloned note will be shown in note tree with given prefix",
|
||||
"prefix_optional": "Prefix (optional)",
|
||||
"clone_to_selected_note": "Clone to selected note <kbd>enter</kbd>",
|
||||
"clone_to_selected_note": "Clone to selected note",
|
||||
"no_path_to_clone_to": "No path to clone to.",
|
||||
"note_cloned": "Note \"{{clonedTitle}}\" has been cloned into \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmation",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "Are you sure you want to remove the note \"{{title}}\" from relation map? ",
|
||||
@@ -87,9 +81,9 @@
|
||||
"delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
|
||||
"erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
|
||||
"erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
|
||||
"notes_to_be_deleted": "Following notes will be deleted ({{- noteCount}})",
|
||||
"notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
|
||||
"no_note_to_delete": "No note will be deleted (only clones).",
|
||||
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{- relationCount}})",
|
||||
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
|
||||
@@ -113,21 +107,20 @@
|
||||
"format_pdf": "PDF - for printing or sharing purposes."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Help (full documentation is available <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Close",
|
||||
"title": "Cheatsheet",
|
||||
"noteNavigation": "Note navigation",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - go up/down in the list of notes",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - collapse/expand node",
|
||||
"goUpDown": "go up/down in the list of notes",
|
||||
"collapseExpand": "collapse/expand node",
|
||||
"notSet": "not set",
|
||||
"goBackForwards": "go back / forwards in the history",
|
||||
"showJumpToNoteDialog": "show <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Jump to\" dialog</a>",
|
||||
"scrollToActiveNote": "scroll to active note",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - jump to parent note",
|
||||
"jumpToParentNote": "jump to parent note",
|
||||
"collapseWholeTree": "collapse whole note tree",
|
||||
"collapseSubTree": "collapse sub-tree",
|
||||
"tabShortcuts": "Tab shortcuts",
|
||||
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (or <kbd>middle mouse click</kbd>) on note link opens note in a new tab",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (or <kbd>Shift+middle mouse click</kbd>) on note link opens and activates the note in a new tab",
|
||||
"newTabNoteLink": "on note link opens note in a new tab",
|
||||
"newTabWithActivationNoteLink": "on note link opens and activates the note in a new tab",
|
||||
"onlyInDesktop": "Only in desktop (Electron build)",
|
||||
"openEmptyTab": "open empty tab",
|
||||
"closeActiveTab": "close active tab",
|
||||
@@ -142,14 +135,14 @@
|
||||
"moveNoteUpHierarchy": "move note up in the hierarchy",
|
||||
"multiSelectNote": "multi-select note above/below",
|
||||
"selectAllNotes": "select all notes in the current level",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - select note",
|
||||
"selectNote": "select note",
|
||||
"copyNotes": "copy active note (or current selection) into clipboard (used for <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">cloning</a>)",
|
||||
"cutNotes": "cut current note (or current selection) into clipboard (used for moving notes)",
|
||||
"pasteNotes": "paste note(s) as sub-note into active note (which is either move or clone depending on whether it was copied or cut into clipboard)",
|
||||
"deleteNotes": "delete note / sub-tree",
|
||||
"editingNotes": "Editing notes",
|
||||
"editNoteTitle": "in tree pane will switch from tree pane into note title. Enter from note title will switch focus to text editor. <kbd>Ctrl+.</kbd> will switch back from editor to tree pane.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - create / edit external link",
|
||||
"createEditLink": "create / edit external link",
|
||||
"createInternalLink": "create internal link",
|
||||
"followLink": "follow link under cursor",
|
||||
"insertDateTime": "insert current date and time at caret position",
|
||||
@@ -169,7 +162,6 @@
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Import into note",
|
||||
"close": "Close",
|
||||
"chooseImportFile": "Choose import file",
|
||||
"importDescription": "Content of the selected file(s) will be imported as child note(s) into",
|
||||
"options": "Options",
|
||||
@@ -196,14 +188,13 @@
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Include note",
|
||||
"close": "Close",
|
||||
"label_note": "Note",
|
||||
"placeholder_search": "search for note by its name",
|
||||
"box_size_prompt": "Box size of the included note:",
|
||||
"box_size_small": "small (~ 10 lines)",
|
||||
"box_size_medium": "medium (~ 30 lines)",
|
||||
"box_size_full": "full (box shows complete text)",
|
||||
"button_include": "Include note <kbd>enter</kbd>"
|
||||
"button_include": "Include note"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Info message",
|
||||
@@ -211,24 +202,21 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "search for note by its name",
|
||||
"close": "Close",
|
||||
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
|
||||
"search_placeholder": "Search for note by its name or type > for commands...",
|
||||
"search_button": "Search in full text"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown import",
|
||||
"close": "Close",
|
||||
"modal_body_text": "Because of browser sandbox it's not possible to directly read clipboard from JavaScript. Please paste the Markdown to import to textarea below and click on Import button",
|
||||
"import_button": "Import Ctrl+Enter",
|
||||
"import_button": "Import",
|
||||
"import_success": "Markdown content has been imported into the document."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Move notes to ...",
|
||||
"close": "Close",
|
||||
"notes_to_move": "Notes to move",
|
||||
"target_parent_note": "Target parent note",
|
||||
"search_placeholder": "search for note by its name",
|
||||
"move_button": "Move to selected note <kbd>enter</kbd>",
|
||||
"move_button": "Move to selected note",
|
||||
"error_no_path": "No path to move to.",
|
||||
"move_success_message": "Selected notes have been moved into "
|
||||
},
|
||||
@@ -236,20 +224,19 @@
|
||||
"change_path_prompt": "Change where to create the new note:",
|
||||
"search_placeholder": "search path by name (default if empty)",
|
||||
"modal_title": "Choose note type",
|
||||
"close": "Close",
|
||||
"modal_body": "Choose note type / template of the new note:",
|
||||
"templates": "Templates:"
|
||||
"templates": "Templates",
|
||||
"builtin_templates": "Built-in Templates"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Password is not set",
|
||||
"close": "Close",
|
||||
"body1": "Protected notes are encrypted using a user password, but password has not been set yet.",
|
||||
"body2": "To be able to protect notes, click <a class=\"open-password-options-button\" href=\"javascript:\">here</a> to open the Options dialog and set your password."
|
||||
"body2": "To be able to protect notes, click the button below to open the Options dialog and set your password.",
|
||||
"go_to_password_options": "Go to Password options"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Close",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"ok": "OK",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -257,12 +244,11 @@
|
||||
"help_title": "Help on Protected notes",
|
||||
"close_label": "Close",
|
||||
"form_label": "To proceed with requested action you need to start protected session by entering password:",
|
||||
"start_button": "Start protected session <kbd>enter</kbd>"
|
||||
"start_button": "Start protected session"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Recent changes",
|
||||
"erase_notes_button": "Erase deleted notes now",
|
||||
"close": "Close",
|
||||
"deleted_notes_message": "Deleted notes have been erased.",
|
||||
"no_changes_message": "No changes yet...",
|
||||
"undelete_link": "undelete",
|
||||
@@ -273,7 +259,6 @@
|
||||
"delete_all_revisions": "Delete all revisions of this note",
|
||||
"delete_all_button": "Delete all revisions",
|
||||
"help_title": "Help on Note Revisions",
|
||||
"close": "Close",
|
||||
"revision_last_edited": "This revision was last edited on {{date}}",
|
||||
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
||||
"no_revisions": "No revisions for this note yet...",
|
||||
@@ -295,7 +280,6 @@
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Sort children by...",
|
||||
"close": "Close",
|
||||
"sorting_criteria": "Sorting criteria",
|
||||
"title": "title",
|
||||
"date_created": "date created",
|
||||
@@ -309,13 +293,12 @@
|
||||
"sort_with_respect_to_different_character_sorting": "sort with respect to different character sorting and collation rules in different languages or regions.",
|
||||
"natural_sort_language": "Natural sort language",
|
||||
"the_language_code_for_natural_sort": "The language code for natural sort, e.g. \"zh-CN\" for Chinese.",
|
||||
"sort": "Sort <kbd>enter</kbd>"
|
||||
"sort": "Sort"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Upload attachments to note",
|
||||
"close": "Close",
|
||||
"choose_files": "Choose files",
|
||||
"files_will_be_uploaded": "Files will be uploaded as attachments into",
|
||||
"files_will_be_uploaded": "Files will be uploaded as attachments into {{noteTitle}}",
|
||||
"options": "Options",
|
||||
"shrink_images": "Shrink images",
|
||||
"upload": "Upload",
|
||||
@@ -443,7 +426,8 @@
|
||||
"other_notes_with_name": "Other notes with {{attributeType}} name \"{{attributeName}}\"",
|
||||
"and_more": "... and {{count}} more.",
|
||||
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>."
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "To add label, just type e.g. <code>#rock</code> or if you want to add also value then e.g. <code>#year = 2020</code>",
|
||||
@@ -762,7 +746,8 @@
|
||||
"invalid_view_type": "Invalid view type '{{type}}'",
|
||||
"calendar": "Calendar",
|
||||
"table": "Table",
|
||||
"geo-map": "Geo Map"
|
||||
"geo-map": "Geo Map",
|
||||
"board": "Board"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "No edited notes on this day yet...",
|
||||
@@ -839,7 +824,8 @@
|
||||
"unknown_label_type": "Unknown label type '{{type}}'",
|
||||
"unknown_attribute_type": "Unknown attribute type '{{type}}'",
|
||||
"add_new_attribute": "Add new attribute",
|
||||
"remove_this_attribute": "Remove this attribute"
|
||||
"remove_this_attribute": "Remove this attribute",
|
||||
"remove_color": "Remove the color label"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "Query",
|
||||
@@ -1115,12 +1101,12 @@
|
||||
"title": "Application Theme",
|
||||
"theme_label": "Theme",
|
||||
"override_theme_fonts_label": "Override theme fonts",
|
||||
"auto_theme": "Auto",
|
||||
"light_theme": "Light",
|
||||
"dark_theme": "Dark",
|
||||
"triliumnext": "TriliumNext Beta (Follow system color scheme)",
|
||||
"triliumnext-light": "TriliumNext Beta (Light)",
|
||||
"triliumnext-dark": "TriliumNext Beta (Dark)",
|
||||
"auto_theme": "Legacy (Follow system color scheme)",
|
||||
"light_theme": "Legacy (Light)",
|
||||
"dark_theme": "Legacy (Dark)",
|
||||
"triliumnext": "Trilium (Follow system color scheme)",
|
||||
"triliumnext-light": "Trilium (Light)",
|
||||
"triliumnext-dark": "Trilium (Dark)",
|
||||
"layout": "Layout",
|
||||
"layout-vertical-title": "Vertical",
|
||||
"layout-horizontal-title": "Horizontal",
|
||||
@@ -1615,7 +1601,7 @@
|
||||
"relation-map": "Relation Map",
|
||||
"note-map": "Note Map",
|
||||
"render-note": "Render Note",
|
||||
"book": "Book",
|
||||
"book": "Collection",
|
||||
"mermaid-diagram": "Mermaid Diagram",
|
||||
"canvas": "Canvas",
|
||||
"web-view": "Web View",
|
||||
@@ -1964,9 +1950,55 @@
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Hide weekends",
|
||||
"display-week-numbers": "Display week numbers"
|
||||
"display-week-numbers": "Display week numbers",
|
||||
"map-style": "Map style:",
|
||||
"max-nesting-depth": "Max nesting depth:",
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vector (Light)",
|
||||
"vector_dark": "Vector (Dark)",
|
||||
"show-scale": "Show scale"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Delete row"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "Delete Note",
|
||||
"move-to": "Move to",
|
||||
"insert-above": "Insert above",
|
||||
"insert-below": "Insert below",
|
||||
"delete-column": "Delete column",
|
||||
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
"export_note_description": "Export current note",
|
||||
"show_attachments_title": "Show Attachments",
|
||||
"show_attachments_description": "View note attachments",
|
||||
"search_notes_title": "Search Notes",
|
||||
"search_notes_description": "Open advanced search",
|
||||
"search_subtree_title": "Search in Subtree",
|
||||
"search_subtree_description": "Search within current subtree",
|
||||
"search_history_title": "Show Search History",
|
||||
"search_history_description": "View previous searches",
|
||||
"configure_launch_bar_title": "Configure Launch Bar",
|
||||
"configure_launch_bar_description": "Open the launch bar configuration, to add or remove items."
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Open externally"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Close",
|
||||
"help_title": "Display more information about this screen"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "The new Trilium theme is now stable",
|
||||
"next_theme_message": "For a while now, we've been working on a new theme to give the application a more modern look.",
|
||||
"next_theme_button": "Switch to the new Trilium theme",
|
||||
"background_effects_title": "Background effects are now stable",
|
||||
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
||||
"background_effects_button": "Enable background effects"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
345
apps/client/src/translations/it/translation.json
Normal file
345
apps/client/src/translations/it/translation.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"about": {
|
||||
"app_version": "Versione dell'app:",
|
||||
"db_version": "Versione DB:",
|
||||
"sync_version": "Versione Sync:",
|
||||
"data_directory": "Cartella dati:",
|
||||
"title": "Informazioni su Trilium Notes",
|
||||
"build_date": "Data della build:",
|
||||
"build_revision": "Revisione della build:",
|
||||
"homepage": "Homepage:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Errore critico",
|
||||
"message": "Si è verificato un errore critico che impedisce l'avvio dell'applicazione client:\n\n{{message}}\n\nQuesto è probabilmente causato da un errore di script inaspettato. Prova a avviare l'applicazione in modo sicuro e controlla il problema."
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Non si è riusciti a caricare uno script personalizzato",
|
||||
"message": "Lo script della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
"note": "Nota",
|
||||
"search_note": "cerca una nota per nome",
|
||||
"link_title_mirrors": "il titolo del collegamento rispecchia il titolo della nota corrente",
|
||||
"link_title_arbitrary": "il titolo del collegamento può essere modificato arbitrariamente",
|
||||
"link_title": "Titolo del collegamento",
|
||||
"button_add_link": "Aggiungi il collegamento <kbd>invio</kbd>",
|
||||
"help_on_links": "Aiuto sui collegamenti"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifica il prefisso del ramo",
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
"affected_notes": "Note influenzate",
|
||||
"include_descendants": "Includi i discendenti della nota selezionata",
|
||||
"available_actions": "Azioni disponibili",
|
||||
"chosen_actions": "Azioni scelte",
|
||||
"execute_bulk_actions": "Esegui le azioni massive",
|
||||
"bulk_actions_executed": "Le azioni massive sono state eseguite con successo.",
|
||||
"none_yet": "Ancora nessuna... aggiungi una azione cliccando su una di quelle disponibili sopra.",
|
||||
"labels": "Etichette",
|
||||
"relations": "Relazioni",
|
||||
"notes": "Note",
|
||||
"other": "Altro"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clona note in...",
|
||||
"help_on_links": "Aiuto sui collegamenti",
|
||||
"notes_to_clone": "Note da clonare",
|
||||
"target_parent_note": "Nodo padre obiettivo",
|
||||
"search_for_note_by_its_name": "cerca una nota per nome",
|
||||
"cloned_note_prefix_title": "Le note clonate saranno mostrate nell'albero delle note con il dato prefisso",
|
||||
"prefix_optional": "Prefisso (opzionale)",
|
||||
"clone_to_selected_note": "Clona sotto la nota selezionata <kbd>invio</kbd>",
|
||||
"no_path_to_clone_to": "Nessun percorso per clonare dentro.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" è stata clonata in \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"confirmation": "Conferma",
|
||||
"are_you_sure_remove_note": "Sei sicuro di voler rimuovere la nota \"{{title}}\" dalla mappa delle relazioni? ",
|
||||
"if_you_dont_check": "Se non lo selezioni, la nota sarà rimossa solamente dalla mappa delle relazioni.",
|
||||
"also_delete_note": "Rimuove anche la nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"delete_notes_preview": "Anteprima di eliminazione delle note",
|
||||
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere disfatto tramite i cambiamenti recenti)",
|
||||
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
|
||||
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
|
||||
"cancel": "Annulla",
|
||||
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
|
||||
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{- relationCount}})",
|
||||
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Chiudi"
|
||||
},
|
||||
"export": {
|
||||
"close": "Chiudi",
|
||||
"export_note_title": "Esporta la nota",
|
||||
"export_status": "Stato dell'esportazione",
|
||||
"export": "Esporta",
|
||||
"choose_export_type": "Scegli prima il tipo di esportazione, per favore",
|
||||
"export_in_progress": "Esportazione in corso: {{progressCount}}",
|
||||
"export_finished_successfully": "Esportazione terminata con successo.",
|
||||
"format_pdf": "PDF- allo scopo di stampa o esportazione.",
|
||||
"export_type_subtree": "Questa nota e tutti i suoi discendenti",
|
||||
"format_html": "HTML - raccomandato in quanto mantiene tutti i formati",
|
||||
"format_html_zip": "HTML in archivio ZIP - questo è raccomandato in quanto conserva tutta la formattazione.",
|
||||
"format_markdown": "MArkdown - questo conserva la maggior parte della formattazione."
|
||||
},
|
||||
"password_not_set": {
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
"body2": "Per proteggere le note, fare clic su <a class=\"open-password-options-button\" href=\"javascript:\">qui</a> per aprire la finestra di dialogo Opzioni e impostare la password."
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Chiudi"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Rimuovi questa azione di ricerca"
|
||||
},
|
||||
"etapi": {
|
||||
"new_token_title": "Nuovo token ETAPI",
|
||||
"new_token_message": "Inserire il nuovo nome del token"
|
||||
},
|
||||
"electron_integration": {
|
||||
"zoom-factor": "Fattore di ingrandimento",
|
||||
"desktop-application": "Applicazione Desktop"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "Cerca \"{{term}}\"",
|
||||
"create-note": "Crea e collega la nota figlia \"{{term}}\"",
|
||||
"insert-external-link": "Inserisci il collegamento esterno a \"{{term}}\"",
|
||||
"clear-text-field": "Pulisci il campo di testo",
|
||||
"show-recent-notes": "Mostra le note recenti",
|
||||
"full-text-search": "Ricerca full text"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota è stata eliminata.",
|
||||
"quick-edit": "Modifica veloce"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crea una nota figlia e aggiungila alla mappa",
|
||||
"create-child-note-instruction": "Clicca sulla mappa per creare una nuova nota qui o premi Escape per uscire.",
|
||||
"unable-to-load-map": "Impossibile caricare la mappa."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Apri la posizione",
|
||||
"remove-from-map": "Rimuovi dalla mappa",
|
||||
"add-note": "Aggiungi un marcatore in questa posizione"
|
||||
},
|
||||
"debug": {
|
||||
"debug": "Debug"
|
||||
},
|
||||
"database_anonymization": {
|
||||
"light_anonymization": "Anonimizzazione parziale",
|
||||
"title": "Anonimizzazione del Database",
|
||||
"full_anonymization": "Anonimizzazione completa",
|
||||
"full_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà (rimuove tutti i contenuti delle note, lasciando solo la struttura e qualche metadato non sensibile) per condividerlo online allo scopo di debugging, senza paura di far trapelare i tuoi dati personali.",
|
||||
"save_fully_anonymized_database": "Salva il database completamente anonimizzato",
|
||||
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
|
||||
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
|
||||
"no_anonymized_database_yet": "Nessun database ancora anonimizzato.",
|
||||
"save_lightly_anonymized_database": "Salva il database parzialmente anonimizzato",
|
||||
"successfully_created_fully_anonymized_database": "Database completamente anonimizzato creato in {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "Database parzialmente anonimizzato creato in {{anonymizedFilePath}}"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Per favore scarica la versione ARM64",
|
||||
"continue_anyway": "Continua Comunque",
|
||||
"dont_show_again": "Non mostrare più questo avviso",
|
||||
"download_link": "Scarica la Versione Nativa"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caratteristiche",
|
||||
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
|
||||
"note_completion_enabled": "Abilita il completamento automatico delle note"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
"new-column": "Nuova colonna",
|
||||
"sort-column-by": "Ordina per \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendente",
|
||||
"sort-column-descending": "Discendente",
|
||||
"sort-column-clear": "Cancella l'ordinamento",
|
||||
"hide-column": "Nascondi la colonna \"{{title}}\"",
|
||||
"show-hide-columns": "Mostra/nascondi le colonne",
|
||||
"row-insert-above": "Inserisci una riga sopra",
|
||||
"row-insert-below": "Inserisci una riga sotto"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Rimuovi questa opzione di ricerca",
|
||||
"failed_rendering": "Opzione di ricerca di rendering non riuscita: {{dto}} con errore: {{error}} {{stack}}"
|
||||
},
|
||||
"ancestor": {
|
||||
"label": "Antenato"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Aggiungi etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"new_value_placeholder": "nuovo valore",
|
||||
"to_value": "al valore"
|
||||
},
|
||||
"update_label_value": {
|
||||
"to_value": "al valore",
|
||||
"label_name_placeholder": "nome dell'etichetta"
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Elimina etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"label_name_title": "Sono ammessi i caratteri alfanumerici, il carattere di sottolineato e i due punti."
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"move-to": "Muovi in...",
|
||||
"cut": "Taglia"
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"copy-link": "Copia collegamento",
|
||||
"paste-as-plain-text": "Incolla come testo semplice"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra."
|
||||
}
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Modifica questa nota"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Scorciatoie"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
"toggle-off-title": "Non condividere la nota"
|
||||
},
|
||||
"search_string": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati"
|
||||
},
|
||||
"search_definition": {
|
||||
"ancestor": "antenato",
|
||||
"debug": "debug",
|
||||
"action": "azione",
|
||||
"add_search_option": "Aggiungi un opzione di ricerca:",
|
||||
"search_string": "cerca la stringa",
|
||||
"limit": "limite"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"board_view": {
|
||||
"insert-below": "Inserisci sotto",
|
||||
"delete-column": "Elimina la colonna",
|
||||
"delete-column-confirmation": "Sei sicuro di vole eliminare questa colonna? Il corrispondente attributo sarà eliminato anche nelle note sotto questa colonna."
|
||||
},
|
||||
"backup": {
|
||||
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
|
||||
"enable_monthly_backup": "Abilita le archiviazioni mensili",
|
||||
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
|
||||
"backup_now": "Archivia adesso",
|
||||
"backup_database_now": "Archivia il database adesso",
|
||||
"existing_backups": "Backup esistenti",
|
||||
"date-and-time": "Data e ora",
|
||||
"path": "Percorso",
|
||||
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
|
||||
"enable_daily_backup": "Abilita le archiviazioni giornaliere",
|
||||
"no_backup_yet": "Ancora nessuna archiviazione"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
|
||||
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
|
||||
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto."
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"check_button": "Controllo dell'integrità del database",
|
||||
"checking_integrity": "Controllo dell'integrità del database in corso...",
|
||||
"title": "Controllo di Integrità del database",
|
||||
"description": "Controllerà che il database non sia corrotto a livello SQLite. Può durare un po' di tempo, a seconda della grandezza del DB.",
|
||||
"integrity_check_failed": "Controllo di integrità fallito: {{results}}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sincronizza",
|
||||
"force_full_sync_button": "Forza una sincronizzazione completa",
|
||||
"failed": "Sincronizzazione fallita: {{message}}"
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Configurazione per la Sincronizzazione",
|
||||
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
|
||||
"test_title": "Test di sincronizzazione",
|
||||
"timeout": "Timeout per la sincronizzazione",
|
||||
"timeout_unit": "millisecondi",
|
||||
"save": "Salva",
|
||||
"help": "Aiuto"
|
||||
},
|
||||
"search_engine": {
|
||||
"save_button": "Salva"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "Chiudi la scheda",
|
||||
"add_new_tab": "Aggiungi una nuova scheda",
|
||||
"close": "Chiudi",
|
||||
"close_other_tabs": "Chiudi le altre schede",
|
||||
"close_right_tabs": "Chiudi le schede a destra",
|
||||
"close_all_tabs": "Chiudi tutte le schede",
|
||||
"reopen_last_tab": "Riapri l'ultima scheda chiusa",
|
||||
"move_tab_to_new_window": "Sposta questa scheda in una nuova finestra",
|
||||
"copy_tab_to_new_window": "Copia questa scheda in una nuova finestra",
|
||||
"new_tab": "Nuova scheda"
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Sommario"
|
||||
},
|
||||
"table_of_contents": {
|
||||
"title": "Sommario"
|
||||
},
|
||||
"tray": {
|
||||
"title": "Vassoio di Sistema",
|
||||
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
|
||||
},
|
||||
"heading_style": {
|
||||
"title": "Stile dell'Intestazione",
|
||||
"plain": "Normale",
|
||||
"underline": "Sottolineato",
|
||||
"markdown": "Stile Markdown"
|
||||
},
|
||||
"highlights_list": {
|
||||
"title": "Punti salienti"
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Punti salienti",
|
||||
"options": "Opzioni"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Ricerca rapida",
|
||||
"searching": "Ricerca in corso..."
|
||||
}
|
||||
}
|
||||
176
apps/client/src/translations/ja/translation.json
Normal file
176
apps/client/src/translations/ja/translation.json
Normal file
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Trilium Notesについて",
|
||||
"homepage": "ホームページ:",
|
||||
"app_version": "アプリのバージョン:",
|
||||
"db_version": "データベースのバージョン:",
|
||||
"sync_version": "同期のバージョン:",
|
||||
"build_date": "Build の日時:",
|
||||
"build_revision": "Build のバージョン:",
|
||||
"data_directory": "データの場所:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "致命的なエラー",
|
||||
"message": "致命的なエラーのせいでアプリをスタートできません:\n\n{{message}}\n\nおそらくスクリプトが予期しないバグを含んでいると思われます。アプリをセーフモードでスタートしてみて下さい。"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "ウィジェットを初期化できませんでした",
|
||||
"message-custom": "ノートID”{{id}}”, ノートタイトル “{{title}}” のカスタムウィジェットを初期化できませんでした:\n\n{{message}}",
|
||||
"message-unknown": "不明なウィジェットが初期化できませんでした。理由は以下の通りです:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "カスタムスクリプトの読み込みに失敗しました",
|
||||
"message": "ノートID”{{id}}”, ノートタイトル “{{title}}” のスクリプトを実行できませんでした。理由は以下の通りです:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "リンクを追加",
|
||||
"note": "ノート",
|
||||
"link_title": "リンクタイトル",
|
||||
"button_add_link": "リンクを追加",
|
||||
"help_on_links": "リンクに関するヘルプ",
|
||||
"search_note": "ノート名で検索",
|
||||
"link_title_mirrors": "リンクタイトルはノートタイトルの変更を反映します",
|
||||
"link_title_arbitrary": "リンクタイトルは自由に変更可能"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "保存"
|
||||
},
|
||||
"global_menu": {
|
||||
"menu": "メニュー",
|
||||
"options": "オプション",
|
||||
"open_new_window": "新しいウィンドウを開く",
|
||||
"zoom": "ズーム",
|
||||
"toggle_fullscreen": "フルスクリーンの切り替え",
|
||||
"reset_zoom_level": "ズームレベルのリセット",
|
||||
"open_dev_tools": "開発者ツールを開く",
|
||||
"open_sql_console": "SQLコンソールを開く",
|
||||
"open_sql_console_history": "SQLコンソールの履歴を開く",
|
||||
"open_search_history": "検索履歴を開く",
|
||||
"show_backend_log": "バックエンドログの表示",
|
||||
"reload_hint": "リロードは、アプリ全体を再起動することなく、視覚的な不具合を解消することができます。",
|
||||
"reload_frontend": "フロントエンドをリロード",
|
||||
"show_hidden_subtree": "隠れたサブツリーを表示",
|
||||
"show_help": "ヘルプを表示",
|
||||
"about": "Trilium Notesについて",
|
||||
"logout": "ログアウト",
|
||||
"show-cheatsheet": "チートシートを表示"
|
||||
},
|
||||
"left_pane_toggle": {
|
||||
"show_panel": "パネルを表示",
|
||||
"hide_panel": "パネルを隠す"
|
||||
},
|
||||
"move_pane_button": {
|
||||
"move_left": "左に移動",
|
||||
"move_right": "右に移動"
|
||||
},
|
||||
"clone_to": {
|
||||
"notes_to_clone": "複製するノート",
|
||||
"target_parent_note": "ターゲットの親ノート",
|
||||
"search_for_note_by_its_name": "ノート名で検索",
|
||||
"cloned_note_prefix_title": "複製されたノートは、指定された接頭辞を付けてノートツリーに表示されます",
|
||||
"prefix_optional": "接頭辞(任意)",
|
||||
"clone_to_selected_note": "選択したノートに複製",
|
||||
"no_path_to_clone_to": "複製先のパスが存在しません。",
|
||||
"note_cloned": "ノート \"{{clonedTitle}}\" は \"{{targetTitle}}\" に複製されました"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_all_clones_description": "すべての複製も削除する(最近の変更では元に戻すことができる)",
|
||||
"erase_notes_description": "通常の(ソフト)削除では、ノートは削除されたものとしてマークされ、一定期間内に(最近の変更で)削除を取り消すことができます。このオプションをオンにすると、ノートは即座に削除され、削除を取り消すことはできません。",
|
||||
"erase_notes_warning": "すべての複製を含め、ノートを完全に消去します(元に戻せません)。これにより、アプリケーションは強制的にリロードされます。",
|
||||
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
|
||||
"no_note_to_delete": "ノートは削除されません(複製のみ)。",
|
||||
"cancel": "キャンセル",
|
||||
"ok": "OK"
|
||||
},
|
||||
"calendar": {
|
||||
"mon": "月",
|
||||
"tue": "火",
|
||||
"wed": "水",
|
||||
"thu": "木",
|
||||
"fri": "金",
|
||||
"sat": "土",
|
||||
"sun": "日",
|
||||
"january": "1月",
|
||||
"febuary": "2月",
|
||||
"march": "3月",
|
||||
"april": "4月",
|
||||
"may": "5月",
|
||||
"june": "6月",
|
||||
"july": "7月",
|
||||
"august": "8月",
|
||||
"september": "9月",
|
||||
"october": "10月",
|
||||
"november": "11月",
|
||||
"december": "12月"
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "ノートアイコンの変更",
|
||||
"category": "カテゴリー:",
|
||||
"search": "検索:",
|
||||
"reset-default": "アイコンをデフォルトに戻す"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "ノートタイプ",
|
||||
"editable": "編集可能"
|
||||
},
|
||||
"i18n": {
|
||||
"title": "ローカライゼーション",
|
||||
"language": "言語",
|
||||
"first-day-of-the-week": "週の最初",
|
||||
"sunday": "日曜日",
|
||||
"monday": "月曜日"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "タブを閉じる",
|
||||
"add_new_tab": "新しいタブ",
|
||||
"close": "閉じる",
|
||||
"close_other_tabs": "他のタブを閉じる",
|
||||
"close_right_tabs": "右側のタブをすべて閉じる",
|
||||
"close_all_tabs": "すべてのタブを閉じる",
|
||||
"reopen_last_tab": "最後に閉じたタブを開く",
|
||||
"move_tab_to_new_window": "このタブを新しいウィンドウに移動する",
|
||||
"copy_tab_to_new_window": "このタブを新しいウィンドウにコピーする"
|
||||
},
|
||||
"tasks": {
|
||||
"due": {
|
||||
"today": "今日",
|
||||
"tomorrow": "明日",
|
||||
"yesterday": "昨日"
|
||||
}
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "書式設定"
|
||||
},
|
||||
"search_definition": {
|
||||
"add_search_option": "検索オプションを追加:",
|
||||
"search_string": "文字列検索",
|
||||
"search_script": "スクリプト検索",
|
||||
"fast_search": "高速検索",
|
||||
"fast_search_description": "高速検索オプションは、ノートの全文検索を無効にし、大規模データベースでの検索を高速化します。",
|
||||
"include_archived": "アーカイブを含む",
|
||||
"include_archived_notes_description": "アーカイブされたノートはデフォルトで検索結果から除外されますが、このオプションを使用すると含まれるようになります。",
|
||||
"order_by": "並べ替え",
|
||||
"limit": "リミット",
|
||||
"limit_description": "検索結果の数を制限する",
|
||||
"debug": "デバッグ",
|
||||
"debug_description": "デバッグは複雑なクエリのデバッグを支援するために、追加のデバッグ情報をコンソールに出力します",
|
||||
"action": "アクション",
|
||||
"search_button": "検索 <kbd>Enter</kbd>",
|
||||
"search_execute": "検索とアクションの実行",
|
||||
"save_to_note": "ノートに保存"
|
||||
},
|
||||
"shortcuts": {
|
||||
"multiple_shortcuts": "同じアクションに対して複数のショートカットを設定する場合、カンマで区切ることができます。",
|
||||
"electron_documentation": "使用可能な修飾キーとキーコードについては、 <a href=\"https://www.electronjs.org/docs/latest/api/accelerator\">Electronのドキュメント</a>を参照してください。",
|
||||
"type_text_to_filter": "テキストを入力してショートカットを絞り込む...",
|
||||
"action_name": "アクション名",
|
||||
"shortcuts": "ショートカット",
|
||||
"default_shortcuts": "デフォルトのショートカットキー",
|
||||
"description": "説明",
|
||||
"reload_app": "リロードして変更を適用する",
|
||||
"set_all_to_default": "すべてのショートカットをデフォルトに戻す",
|
||||
"confirm_reset": "キーボードショートカットをすべてデフォルトにリセットしますか?"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,414 @@
|
||||
{
|
||||
"revisions": {
|
||||
"delete_button": ""
|
||||
},
|
||||
"code_block": {
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre o Trilium Notes",
|
||||
"homepage": "Página inicial:",
|
||||
"app_version": "Versão do App:",
|
||||
"db_version": "Versão do db:",
|
||||
"sync_version": "Versão de sincronização:",
|
||||
"build_date": "Data de compilação:",
|
||||
"build_revision": "Revisão da compilação:",
|
||||
"data_directory": "Diretório de dados:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Erro crítico",
|
||||
"message": "Ocorreu um erro crítico que impede a inicialização do aplicativo cliente:\n\n{{message}}\n\nIsso provavelmente foi causado por um script que falhou de maneira inesperada. Tente iniciar o aplicativo no modo seguro e resolva o problema."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Falha ao inicializar um widget",
|
||||
"message-custom": "O widget personalizado da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser inicializado devido a:\n\n{{message}}",
|
||||
"message-unknown": "Widget desconhecido não pôde ser inicializado devido a:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Falha para carregar o script customizado",
|
||||
"message": "O script da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser executado devido a:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Adicionar link",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"note": "Nota",
|
||||
"search_note": "pesquisar nota pelo nome",
|
||||
"link_title_mirrors": "o título do link reflete o título atual da nota",
|
||||
"link_title_arbitrary": "o título do link pode ser alterado livremente",
|
||||
"link_title": "Titulo do link",
|
||||
"button_add_link": "Adicionar link"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"prefix": "Prefixo: ",
|
||||
"save": "Salvar",
|
||||
"edit_branch_prefix": "Editar Prefixo do Branch",
|
||||
"help_on_tree_prefix": "Ajuda sobre o prefixo da árvore de notas",
|
||||
"branch_prefix_saved": "O prefixo de ramificação foi salvo."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Ações em massa",
|
||||
"affected_notes": "Notas Afetadas",
|
||||
"include_descendants": "Incluir notas filhas das notas selecionadas",
|
||||
"available_actions": "Ações disponíveis",
|
||||
"chosen_actions": "Ações selecionadas",
|
||||
"execute_bulk_actions": "Executar ações em massa",
|
||||
"bulk_actions_executed": "As ações em massa foram concluídas com sucesso.",
|
||||
"none_yet": "Nenhuma até agora... adicione uma ação clicando em uma das disponíveis acima.",
|
||||
"labels": "Etiquetas",
|
||||
"relations": "Relações",
|
||||
"notes": "Notas",
|
||||
"other": "Outros"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clonar notas para...",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"notes_to_clone": "Notas para clonar",
|
||||
"search_for_note_by_its_name": "pesquisar nota pelo nome",
|
||||
"cloned_note_prefix_title": "A nota clonada aparecerá na árvore de notas com o prefixo fornecido",
|
||||
"prefix_optional": "Prefixo (opcional)",
|
||||
"no_path_to_clone_to": "Nenhum caminho para clonar.",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"clone_to_selected_note": "Clonar para a nota selecionada",
|
||||
"note_cloned": "A nota \"{{clonedTitle}}\" foi clonada para \"{{targetTitle}}\""
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} nota enfileirada para indexação",
|
||||
"n_notes_queued_1": "{{ count }} notas enfileiradas para indexação",
|
||||
"n_notes_queued_2": "{{ count }} notas enfileiradas para indexação",
|
||||
"notes_indexed_0": "{{ count }} nota indexada",
|
||||
"notes_indexed_1": "{{ count }} notas indexadas",
|
||||
"notes_indexed_2": "{{ count }} notas indexadas"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmação",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "Tem certeza de que deseja remover a nota '{{title}}' do mapa de relações? ",
|
||||
"if_you_dont_check": "Se você não marcar isso, a nota será removida apenas do mapa de relações.",
|
||||
"also_delete_note": "Também excluir a nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Excluir pré-visualização de notas",
|
||||
"close": "Fechar",
|
||||
"delete_all_clones_description": "Excluir também todos os clones (pode ser desfeito em alterações recentes)",
|
||||
"erase_notes_description": "A exclusão normal (suave) apenas marca as notas como excluídas, permitindo que sejam recuperadas (no diálogo de alterações recentes) dentro de um período de tempo. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
|
||||
"erase_notes_warning": "Apagar notas permanentemente (não pode ser desfeito), incluindo todos os clones. Isso forçará o recarregamento do aplicativo.",
|
||||
"notes_to_be_deleted": "As seguintes notas serão excluídas ({{notesCount}})",
|
||||
"no_note_to_delete": "Nenhuma nota será excluída (apenas os clones).",
|
||||
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e excluídas ({{ relationCount}})",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "A nota {{- note}} (a ser excluída) está referenciada pela relação {{- relation}} originada de {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Exportar nota",
|
||||
"close": "Fechar",
|
||||
"export_type_subtree": "Esta nota e todos os seus descendentes",
|
||||
"format_html": "HTML – recomendado, pois mantém toda a formatação",
|
||||
"format_html_zip": "HTML em arquivo ZIP – recomendado, pois isso preserva toda a formatação.",
|
||||
"format_markdown": "Markdown – isso preserva a maior parte da formatação.",
|
||||
"format_opml": "OPML - formato de intercâmbio de outliners apenas para texto. Formatação, imagens e arquivos não estão incluídos.",
|
||||
"opml_version_1": "OPML v1.0 – apenas texto simples",
|
||||
"opml_version_2": "OPML v2.0 – permite também HTML",
|
||||
"export_type_single": "Apenas esta nota, sem seus descendentes",
|
||||
"export": "Exportar",
|
||||
"choose_export_type": "Por favor, escolha primeiro o tipo de exportação",
|
||||
"export_status": "Status da exportação",
|
||||
"export_in_progress": "Exportação em andamento: {{progressCount}}",
|
||||
"export_finished_successfully": "Exportação concluída com sucesso.",
|
||||
"format_pdf": "PDF – para impressão ou compartilhamento."
|
||||
},
|
||||
"help": {
|
||||
"noteNavigation": "Navegação de notas",
|
||||
"goUpDown": "subir/descer na lista de notas",
|
||||
"collapseExpand": "recolher/expandir nó",
|
||||
"notSet": "não definido",
|
||||
"goBackForwards": "voltar / avançar no histórico",
|
||||
"showJumpToNoteDialog": "mostrar diálogo <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Ir para\"</a>",
|
||||
"scrollToActiveNote": "rolar até a nota atual",
|
||||
"jumpToParentNote": "ir para a nota pai",
|
||||
"collapseWholeTree": "recolher toda a árvore de notas",
|
||||
"collapseSubTree": "recolher subárvore",
|
||||
"tabShortcuts": "Atalhos de abas",
|
||||
"newTabNoteLink": "em um link de nota abre a nota em uma nova aba",
|
||||
"newTabWithActivationNoteLink": "em um link de nota abre e ativa a nota em uma nova aba",
|
||||
"onlyInDesktop": "Apenas na versão para desktop (compilação Electron)",
|
||||
"openEmptyTab": "abrir aba vazia",
|
||||
"closeActiveTab": "fechar aba ativa",
|
||||
"activateNextTab": "ativar próxima aba",
|
||||
"activatePreviousTab": "ativar aba anterior",
|
||||
"creatingNotes": "Criando notas",
|
||||
"createNoteAfter": "criar nova nota após a nota atual",
|
||||
"createNoteInto": "criar nova subnota dentro da nota atual",
|
||||
"editBranchPrefix": "editar <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefixo</a> do clone da nota ativa",
|
||||
"movingCloningNotes": "Movendo / clonando notas",
|
||||
"moveNoteUpDown": "mover nota para cima/baixo na lista de notas",
|
||||
"moveNoteUpHierarchy": "mover nota para cima na hierarquia",
|
||||
"multiSelectNote": "selecionar múltiplas notas acima/abaixo",
|
||||
"selectAllNotes": "selecionar todas as notas no nível atual",
|
||||
"selectNote": "selecionar nota",
|
||||
"copyNotes": "copiar nota ativa (ou seleção atual) para a área de transferência (usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonar</a>)",
|
||||
"cutNotes": "recortar nota atual (ou seleção atual) para a área de transferência (usado para mover notas)",
|
||||
"pasteNotes": "colar nota(s) como subnota dentro da nota ativa (o que pode ser mover ou clonar dependendo se foi copiado ou recortado para a área de transferência)",
|
||||
"deleteNotes": "excluir nota / subárvore",
|
||||
"editingNotes": "Edição de notas",
|
||||
"editNoteTitle": "no painel de árvore, a navegação mudará do painel de árvore para o título da nota. Pressionar Enter no título da nota mudará o foco para o editor de texto. <kbd>Ctrl+.</kbd> mudará o foco de volta do editor para o painel de árvore.",
|
||||
"createEditLink": "criar / editar link externo",
|
||||
"createInternalLink": "criar link interno",
|
||||
"followLink": "seguir link sob o cursor",
|
||||
"insertDateTime": "inserir data e hora atual na posição do cursor",
|
||||
"jumpToTreePane": "ir para a árvore de notas e rolar até a nota ativa",
|
||||
"markdownAutoformat": "Autoformatação estilo Markdown",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> etc. seguidos de espaço para títulos",
|
||||
"bulletList": "<code>*</code> ou <code>-</code> seguidos de espaço para lista com marcadores",
|
||||
"numberedList": "<code>1.</code> ou <code>1)</code> seguidos de espaço para lista numerada",
|
||||
"blockQuote": "comece uma linha com <code>></code> seguido de espaço para citação em bloco",
|
||||
"troubleshooting": "Solução de problemas",
|
||||
"reloadFrontend": "recarregar o frontend do Trilium",
|
||||
"showDevTools": "mostrar ferramentas de desenvolvedor",
|
||||
"showSQLConsole": "mostrar console SQL",
|
||||
"other": "Outros",
|
||||
"quickSearch": "focar no campo de pesquisa rápida",
|
||||
"inPageSearch": "pesquisa na página",
|
||||
"title": "Folha de Dicas"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Importar para a nota",
|
||||
"chooseImportFile": "Escolher arquivo para importar",
|
||||
"importDescription": "O conteúdo do(s) arquivo(s) selecionado(s) será importado como nota(s) filha(s) em",
|
||||
"options": "Opções",
|
||||
"safeImportTooltip": "Arquivos de exportação Trilium<code> .zip</code> podem conter scripts executáveis que podem apresentar comportamentos prejudiciais. A importação segura desativará a execução automática de todos os scripts importados. Desmarque “Importação segura” apenas se o arquivo de importação contiver scripts executáveis e você confiar totalmente no conteúdo do arquivo importado.",
|
||||
"safeImport": "Importação segura",
|
||||
"explodeArchivesTooltip": "Se esta opção estiver marcada, o Trilium irá ler arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code> e criar notas a partir dos arquivos contidos nesses arquivos compactados. Se estiver desmarcada, o Trilium irá anexar os próprios arquivos compactados à nota.",
|
||||
"explodeArchives": "Ler conteúdos de arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Se você marcar esta opção, o Trilium tentará reduzir o tamanho das imagens importadas por meio de escala e otimização, o que pode afetar a qualidade visual das imagens. Se desmarcada, as imagens serão importadas sem alterações.</p><p>Isso não se aplica a importações de arquivos <code>.zip</code> com metadados, pois presume-se que esses arquivos já estejam otimizados.</p>",
|
||||
"shrinkImages": "Reduzir imagens",
|
||||
"textImportedAsText": "Importar arquivos HTML, Markdown e TXT como notas de texto caso não esteja claro pelos metadados",
|
||||
"codeImportedAsCode": "Importar arquivos de código reconhecidos (por exemplo, <code>.json</code>) como notas de código caso não esteja claro pelos metadados",
|
||||
"replaceUnderscoresWithSpaces": "Substituir sublinhados por espaços nos nomes das notas importadas",
|
||||
"import": "Importar",
|
||||
"failed": "Falha na importação: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "Tags de importação HTML",
|
||||
"description": "Configurar quais tags HTML devem ser preservadas ao importar notas. As tags que não estiverem nesta lista serão removidas durante a importação. Algumas tags (como 'script') são sempre removidas por motivos de segurança.",
|
||||
"placeholder": "Digite as tags HTML, uma por linha",
|
||||
"reset_button": "Redefinir para lista padrão"
|
||||
},
|
||||
"import-status": "Status da importação",
|
||||
"in-progress": "Importação em andamento: {{progress}}",
|
||||
"successful": "Importação concluída com sucesso."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Incluir nota",
|
||||
"label_note": "Nota",
|
||||
"placeholder_search": "pesquisar nota pelo nome",
|
||||
"box_size_prompt": "Dimensão da caixa da nota incluída:",
|
||||
"box_size_small": "pequeno (~ 10 linhas)",
|
||||
"box_size_medium": "médio (~ 30 linhas)",
|
||||
"box_size_full": "completo (a caixa exibe o texto completo)",
|
||||
"button_include": "Incluir nota"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Mensagem informativa",
|
||||
"closeButton": "Fechar",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pesquise uma nota pelo nome ou digite > para comandos...",
|
||||
"search_button": "Pesquisar em texto completo"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importar Markdown",
|
||||
"modal_body_text": "Por motivos de segurança (sandbox do navegador), o JavaScript não pode acessar diretamente a área de transferência. Por favor, cole o conteúdo Markdown na área de texto abaixo e clique em Importar",
|
||||
"import_button": "Importar",
|
||||
"import_success": "O conteúdo Markdown foi importado para o documento."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Mover notas para...",
|
||||
"notes_to_move": "Notas para mover",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"search_placeholder": "pesquisar nota pelo nome",
|
||||
"move_button": "Mover para a nota selecionada",
|
||||
"error_no_path": "Nenhum caminho para mover.",
|
||||
"move_success_message": "As notas selecionadas foram movidas para "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Alterar onde criar a nova nota:",
|
||||
"search_placeholder": "buscar caminho pelo nome (valor padrão se não for preenchido)",
|
||||
"modal_title": "Escolher tipo de nota",
|
||||
"modal_body": "Escolha o tipo/modelo da nova nota:",
|
||||
"templates": "Modelos",
|
||||
"builtin_templates": "Modelos Incorporados"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "A senha não está definida",
|
||||
"body1": "Notas protegidas são criptografadas usando uma senha do usuário, mas a senha ainda não foi definida.",
|
||||
"body2": "Para poder proteger notas, clique no botão abaixo para abrir a caixa de diálogo de Opções e definir sua senha.",
|
||||
"go_to_password_options": "Ir para opções de Senha"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Sessão Protegida",
|
||||
"help_title": "Ajuda sobre notas protegidas",
|
||||
"close_label": "Fechar",
|
||||
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:",
|
||||
"start_button": "Iniciar sessão protegida <kbd>enter</kbd>"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Alterações recentes",
|
||||
"erase_notes_button": "Remover permanentemente as notas excluídas agora",
|
||||
"deleted_notes_message": "As notas excluídas foram removidas permanentemente.",
|
||||
"no_changes_message": "Nenhuma alteração ainda...",
|
||||
"undelete_link": "Restaurar",
|
||||
"confirm_undelete": "Você deseja restaurar esta nota e suas subnotas?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Versões da nota",
|
||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||
"delete_all_button": "Excluir todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
"confirm_restore": "Deseja restaurar esta versão? Isso irá substituir o título e o conteúdo atuais da nota por esta versão.",
|
||||
"delete_button": "Excluir",
|
||||
"confirm_delete": "Deseja excluir esta versão?",
|
||||
"revisions_deleted": "As versões da nota foram removidas.",
|
||||
"revision_restored": "A versão da nota foi restaurada.",
|
||||
"revision_deleted": "A versão da nota foi excluída.",
|
||||
"snapshot_interval": "Intervalo de captura das versões da nota: {{seconds}}s.",
|
||||
"maximum_revisions": "Limite de capturas das versões da nota: {{number}}.",
|
||||
"settings": "Configurações de versões da nota",
|
||||
"download_button": "Download",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Tamanho do arquivo:",
|
||||
"preview": "Visualizar:",
|
||||
"preview_not_available": "A visualização não está disponível para este tipo de nota."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Ordenar notas filhas por...",
|
||||
"sorting_criteria": "Critérios de ordenação",
|
||||
"title": "título",
|
||||
"date_created": "data de criação",
|
||||
"date_modified": "data de modificação",
|
||||
"sorting_direction": "Direção de ordenação",
|
||||
"ascending": "crescente",
|
||||
"descending": "decrescente",
|
||||
"folders": "Pastas",
|
||||
"sort_folders_at_top": "colocar pastas no topo",
|
||||
"natural_sort": "Ordenação Natural",
|
||||
"sort_with_respect_to_different_character_sorting": "classificar de acordo com diferentes regras de ordenação de caracteres e colação em diferentes idiomas ou regiões.",
|
||||
"natural_sort_language": "Linguagem da ordenação natural",
|
||||
"the_language_code_for_natural_sort": "O código do idioma para ordenação natural, por exemplo, \"zh-CN\" para chinês.",
|
||||
"sort": "Ordenar <kbd>enter</kbd>"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Enviar anexos para a nota",
|
||||
"choose_files": "Escolher arquivos",
|
||||
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para",
|
||||
"options": "Opções",
|
||||
"shrink_images": "Reduzir imagens",
|
||||
"upload": "Enviar",
|
||||
"tooltip": "Se você marcar esta opção, o Trilium tentará reduzir as imagens enviadas redimensionando e otimizando, o que pode afetar a qualidade visual percebida. Se desmarcada, as imagens serão enviadas sem alterações."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Título Detalhado do Atributo",
|
||||
"close_button_title": "Cancelar alterações e fechar",
|
||||
"attr_is_owned_by": "O atributo pertence a",
|
||||
"attr_name_title": "O nome do atributo pode ser composto apenas por caracteres alfanuméricos, dois-pontos e sublinhado",
|
||||
"name": "Nome",
|
||||
"value": "Valor",
|
||||
"target_note_title": "Relação é uma conexão nomeada entre a nota de origem e a nota de destino.",
|
||||
"target_note": "Nota de destino",
|
||||
"promoted_title": "O atributo promovido é exibido de forma destacada na nota.",
|
||||
"promoted": "Promovido",
|
||||
"promoted_alias_title": "O nome a ser exibido na interface de atributos promovidos.",
|
||||
"promoted_alias": "Alias",
|
||||
"multiplicity_title": "Multiplicidade define quantos atributos com o mesmo nome podem ser criados — no máximo 1 ou mais de 1.",
|
||||
"multiplicity": "Multiplicidade",
|
||||
"single_value": "Valor único",
|
||||
"multi_value": "Valor múltiplo",
|
||||
"label_type_title": "O tipo do rótulo ajudará o Trilium a escolher a interface adequada para inserir o valor do rótulo.",
|
||||
"label_type": "Tipo",
|
||||
"text": "Texto",
|
||||
"number": "Número",
|
||||
"boolean": "Booleano",
|
||||
"date": "Data",
|
||||
"date_time": "Data e Hora",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision_title": "Qual número de dígitos após o ponto decimal deve estar disponível na interface de configuração de valor.",
|
||||
"precision": "Precisão",
|
||||
"digits": "dígitos",
|
||||
"inverse_relation_title": "Configuração opcional para definir a qual relação esta é oposta. Exemplo: Pai - Filho são relações inversas entre si.",
|
||||
"inverse_relation": "Relação inversa",
|
||||
"inheritable_title": "O atributo herdável será transmitido para todos os descendentes deste ramo.",
|
||||
"inheritable": "Herdável",
|
||||
"save_and_close": "Salvar e fechar <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Excluir",
|
||||
"related_notes_title": "Outras notas com este rótulo",
|
||||
"more_notes": "Mais notas",
|
||||
"label": "Detalhe do rótulo",
|
||||
"label_definition": "Detalhe da definição do rótulo",
|
||||
"relation": "Detalhe da relação",
|
||||
"relation_definition": "Detalhe da definição da relação",
|
||||
"disable_versioning": "desativa a versão automática. Útil, por exemplo, para notas grandes, mas sem importância – como grandes bibliotecas JS usadas para scripts",
|
||||
"calendar_root": "marca a nota que deve ser usada como raiz para notas diárias. Apenas uma deve ser marcada assim.",
|
||||
"archived": "notas com este rótulo não serão exibidas por padrão nos resultados de busca (também nos diálogos Ir para, Adicionar link, etc).",
|
||||
"exclude_from_export": "notas (junto com sua subárvore) não serão incluídas em nenhuma exportação de notas",
|
||||
"run": "define em quais eventos o script deve ser executado. Os valores possíveis são:\n<ul>\n<li>frontendStartup - quando o frontend do Trilium inicia (ou é atualizado), mas não no celular.</li>\n<li>mobileStartup - quando o frontend do Trilium inicia (ou é atualizado), no celular.</li>\n<li>backendStartup - quando o backend do Trilium inicia</li>\n<li>hourly - executa uma vez por hora. Você pode usar o rótulo adicional <code>runAtHour</code> para especificar em qual hora.</li>\n<li>daily - executa uma vez por dia</li>\n</ul>",
|
||||
"run_on_instance": "Define em qual instância do Trilium isso deve ser executado. Por padrão, todas as instâncias.",
|
||||
"run_at_hour": "Em qual hora isso deve ser executado. Deve ser usado junto com <code>#run=hourly</code>. Pode ser definido várias vezes para executar mais de uma vez ao dia.",
|
||||
"disable_inclusion": "scripts com este rótulo não serão incluídos na execução do script pai.",
|
||||
"sorted": "mantém as notas filhas ordenadas alfabeticamente pelo título",
|
||||
"sort_direction": "ASC (padrão) ou DESC",
|
||||
"sort_folders_first": "Pastas (notas com filhos) devem ser ordenadas no topo",
|
||||
"top": "mantenha a nota fornecida no topo em seu pai (aplica-se apenas a pais ordenados)",
|
||||
"hide_promoted_attributes": "Ocultar atributos promovidos nesta nota",
|
||||
"read_only": "o editor está em modo somente leitura. Funciona apenas para notas de texto e código.",
|
||||
"auto_read_only_disabled": "notas de texto/código podem ser automaticamente configuradas para modo de leitura quando são muito grandes. Você pode desabilitar esse comportamento por nota adicionando este rótulo à nota",
|
||||
"app_css": "marca notas CSS que são carregadas no aplicativo Trilium e, portanto, podem ser usadas para modificar a aparência do Trilium.",
|
||||
"app_theme": "marca notas CSS que são temas completos do Trilium e, portanto, estão disponíveis nas opções do Trilium.",
|
||||
"app_theme_base": "defina como \"next\", \"next-light\" ou \"next-dark\" para usar o tema TriliumNext correspondente (auto, claro ou escuro) como base para um tema personalizado, em vez do tema legado.",
|
||||
"css_class": "o valor deste rótulo é então adicionado como classe CSS ao nó que representa a nota específica na árvore. Isso pode ser útil para temas avançados. Pode ser usado em notas de modelo.",
|
||||
"icon_class": "o valor deste rótulo é adicionado como uma classe CSS ao ícone na árvore, o que pode ajudar a distinguir visualmente as notas na árvore. Um exemplo pode ser bx bx-home – os ícones são retirados do boxicons. Pode ser usado em notas de modelo.",
|
||||
"page_size": "número de itens por página na listagem de notas",
|
||||
"custom_request_handler": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"custom_resource_provider": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"widget": "marca esta nota como um widget personalizado que será adicionado à árvore de componentes do Trilium",
|
||||
"workspace": "marca esta nota como um espaço de trabalho, o que permite fácil hoisting",
|
||||
"workspace_icon_class": "define a classe CSS do ícone box que será usada na aba quando esta nota for hoisted",
|
||||
"workspace_tab_background_color": "cor CSS usada na aba da nota quando esta nota é hoisted",
|
||||
"workspace_calendar_root": "Define a raiz do calendário por espaço de trabalho",
|
||||
"workspace_template": "Esta nota aparecerá na seleção de modelos disponíveis ao criar uma nova nota, mas apenas quando estiver destacada em um espaço de trabalho que contenha este modelo",
|
||||
"search_home": "novas notas de pesquisa serão criadas como filhas desta nota",
|
||||
"workspace_search_home": "novas notas de pesquisa serão criadas como filhas desta nota quando ela for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"inbox": "localização padrão da caixa de entrada para novas notas – quando você cria uma nota usando o botão \"nova nota\" na barra lateral, as notas serão criadas como notas filhas na nota marcada com o rótulo <code>#inbox</code>.",
|
||||
"workspace_inbox": "local padrão da caixa de entrada para novas notas quando esta nota for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"sql_console_home": "localização padrão das notas do console SQL",
|
||||
"bookmark_folder": "nota com este rótulo aparecerá nos favoritos como uma pasta (permitindo acesso aos seus filhos)",
|
||||
"share_hidden_from_tree": "esta nota está oculta na árvore de navegação à esquerda, mas ainda pode ser acessada via sua URL",
|
||||
"share_external_link": "a nota funcionará como um link para um site externo na árvore de compartilhamento",
|
||||
"share_alias": "defina um alias por meio do qual a nota ficará disponível em https://your_trilium_host/share/[your_alias]",
|
||||
"share_omit_default_css": "o CSS padrão da página de compartilhamento será omitido. Use quando você fizer alterações extensas de estilo.",
|
||||
"share_root": "marca a nota que é servida na raiz de /share.",
|
||||
"share_description": "defina o texto a ser adicionado à meta tag HTML \"description\"",
|
||||
"share_raw": "a nota será servida em seu formato bruto, sem o wrapper HTML",
|
||||
"share_disallow_robot_indexing": "impedirá que robôs indexem esta nota por meio do cabeçalho <code>X-Robots-Tag: noindex</code>",
|
||||
"share_credentials": "exigir credenciais para acessar esta nota compartilhada. O valor deve estar no formato 'usuário:senha'. Não se esqueça de tornar esta configuração herdável para que seja aplicada às notas-filhas/imagens.",
|
||||
"share_index": "notas com este rótulo irão listar todas as raízes das notas compartilhadas",
|
||||
"display_relations": "nomes das relações separados por vírgula que devem ser exibidos. Todas as outras serão ocultadas.",
|
||||
"hide_relations": "nomes das relações separados por vírgula que devem ser ocultados. Todas as outras serão exibidas.",
|
||||
"title_template": "Título padrão das notas criadas como filhas desta nota. O valor é avaliado como uma string JavaScript e pode ser enriquecido com conteúdo dinâmico usando as variáveis injetadas <code>now</code> e <code>parentNote</code>. Exemplos:\n\n<ul>\n <li><code>${parentNote.getLabelValue('authorName')}'s literary works</code></li>\n <li><code>Log for ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n</ul>\n\nVeja a <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">wiki com detalhes</a>, a documentação da API para <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> e para <a href=\"https://day.js.org/docs/en/display/format\">now</a> para mais informações.",
|
||||
"template": "Esta nota aparecerá na seleção de modelos disponíveis ao criar uma nova nota",
|
||||
"toc": "<code>#toc</code> ou <code>#toc=show</code> irá forçar a exibição do Sumário, <code>#toc=hide</code> irá forçar que ele fique oculto. Se o rótulo não existir, será considerado o ajuste global",
|
||||
"color": "define a cor da nota na árvore de notas, links etc. Use qualquer valor de cor CSS válido, como 'red' ou #a13d5f",
|
||||
"keyboard_shortcut": "Define um atalho de teclado que irá pular imediatamente para esta nota. Exemplo: 'ctrl+alt+e'. É necessário recarregar o frontend para que a alteração tenha efeito."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1237
apps/client/src/translations/ru/translation.json
Normal file
1237
apps/client/src/translations/ru/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
9
apps/client/src/translations/sl/translation.json
Normal file
9
apps/client/src/translations/sl/translation.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Podrobnosti Trilium Notes",
|
||||
"homepage": "Domača stran:",
|
||||
"app_version": "Verzija aplikacije:",
|
||||
"db_version": "Verzija DB:",
|
||||
"sync_version": "Verzija Sync:"
|
||||
}
|
||||
}
|
||||
497
apps/client/src/translations/sr/translation.json
Normal file
497
apps/client/src/translations/sr/translation.json
Normal file
@@ -0,0 +1,497 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "O Trilium Belеškama",
|
||||
"homepage": "Početna stranica:",
|
||||
"app_version": "Verzija aplikacije:",
|
||||
"db_version": "Verzija baze podataka:",
|
||||
"sync_version": "Verzija sinhronizacije:",
|
||||
"build_date": "Datum izgradnje:",
|
||||
"build_revision": "Revizija izgradnje:",
|
||||
"data_directory": "Direktorijum sa podacima:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritična greška",
|
||||
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Pokretanje vidžeta nije uspelo",
|
||||
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
|
||||
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Pokretanje prilagođene skripte neuspešno",
|
||||
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Dodaj link",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"note": "Beleška",
|
||||
"search_note": "potražite belešku po njenom imenu",
|
||||
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
|
||||
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
|
||||
"link_title": "Naziv linka",
|
||||
"button_add_link": "Dodaj link <kbd>enter</kbd>"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Izmeni prefiks grane",
|
||||
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
|
||||
"prefix": "Prefiks: ",
|
||||
"save": "Sačuvaj",
|
||||
"branch_prefix_saved": "Prefiks grane je sačuvan."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Grupne akcije",
|
||||
"affected_notes": "Pogođene beleške",
|
||||
"include_descendants": "Obuhvati potomke izabranih beleški",
|
||||
"available_actions": "Dostupne akcije",
|
||||
"chosen_actions": "Izabrane akcije",
|
||||
"execute_bulk_actions": "Izvrši grupne akcije",
|
||||
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
|
||||
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
|
||||
"labels": "Oznake",
|
||||
"relations": "Odnosi",
|
||||
"notes": "Beleške",
|
||||
"other": "Ostalo"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Klonirajte beleške u...",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"notes_to_clone": "Beleške za kloniranje",
|
||||
"target_parent_note": "Ciljna nadređena beleška",
|
||||
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
|
||||
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
|
||||
"prefix_optional": "Prefiks (opciono)",
|
||||
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
|
||||
"no_path_to_clone_to": "Nema putanje za kloniranje.",
|
||||
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Potvrda",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
|
||||
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
|
||||
"also_delete_note": "Takođe obriši belešku"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Obriši pregled beleške",
|
||||
"close": "Zatvori",
|
||||
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
|
||||
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
|
||||
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
|
||||
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
|
||||
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Izvezi belešku",
|
||||
"close": "Zatvori",
|
||||
"export_type_subtree": "Ova beleška i svi njeni potomci",
|
||||
"format_html": "HTML - preporučuje se jer čuva formatiranje",
|
||||
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
|
||||
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
|
||||
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
|
||||
"opml_version_1": "OPML v1.0 - samo običan tekst",
|
||||
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
|
||||
"export_type_single": "Samo ovu belešku bez njenih potomaka",
|
||||
"export": "Izvoz",
|
||||
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
|
||||
"export_status": "Status izvoza",
|
||||
"export_in_progress": "Izvoz u toku: {{progressCount}}",
|
||||
"export_finished_successfully": "Izvoz je uspešno završen.",
|
||||
"format_pdf": "PDF - za namene štampanja ili deljenja."
|
||||
},
|
||||
"help": {
|
||||
"noteNavigation": "Navigacija beleški",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
|
||||
"notSet": "nije podešeno",
|
||||
"goBackForwards": "idi u nazad/napred kroz istoriju",
|
||||
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
|
||||
"scrollToActiveNote": "skroluj do aktivne beleške",
|
||||
"jumpToParentNote": "idi do nadređene beleške",
|
||||
"collapseWholeTree": "sakupi celo drvo beleški",
|
||||
"collapseSubTree": "sakupi pod-drvo",
|
||||
"tabShortcuts": "Prečice na karticama",
|
||||
"newTabNoteLink": "na link beleške otvara belešku u novoj kartici",
|
||||
"newTabWithActivationNoteLink": "na link beleške otvara i aktivira belešku u novoj kartici",
|
||||
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
|
||||
"openEmptyTab": "otvori praznu karticu",
|
||||
"closeActiveTab": "zatvori aktivnu karticu",
|
||||
"activateNextTab": "aktiviraj narednu karticu",
|
||||
"activatePreviousTab": "aktiviraj prethodnu karticu",
|
||||
"creatingNotes": "Pravljenje beleški",
|
||||
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
|
||||
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
|
||||
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
|
||||
"movingCloningNotes": "Premeštanje / kloniranje beleški",
|
||||
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
|
||||
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
|
||||
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
|
||||
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
|
||||
"selectNote": "izaberi belešku",
|
||||
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
|
||||
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
|
||||
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
|
||||
"deleteNotes": "obriši belešku / podstablo",
|
||||
"editingNotes": "Izmena beleški",
|
||||
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
|
||||
"createEditLink": "napravi / izmeni spoljašnji link",
|
||||
"createInternalLink": "napravi unutrašnji link",
|
||||
"followLink": "prati link ispod kursora",
|
||||
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
|
||||
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
|
||||
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
|
||||
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
|
||||
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
|
||||
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
|
||||
"troubleshooting": "Rešavanje problema",
|
||||
"reloadFrontend": "ponovo učitaj Trilium frontend",
|
||||
"showDevTools": "prikaži alate za programere",
|
||||
"showSQLConsole": "prikaži SQL konzolu",
|
||||
"other": "Ostalo",
|
||||
"quickSearch": "fokus na unos za brzu pretragu",
|
||||
"inPageSearch": "pretraga unutar stranice"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Uvezi u belešku",
|
||||
"chooseImportFile": "Izaberi datoteku za uvoz",
|
||||
"importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u",
|
||||
"options": "Opcije",
|
||||
"safeImportTooltip": "Trilium <code>.zip</code> izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.",
|
||||
"safeImport": "Bezbedan uvoz",
|
||||
"explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati <code>.zip</code>, <code>.enex</code> i <code>.opml</code> datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.",
|
||||
"explodeArchives": "Pročitaj sadržaj <code>.zip</code>, <code>.enex</code> i <code>.opml</code> arhiva.",
|
||||
"shrinkImagesTooltip": "<p>Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.</p><p>Ovo se ne primenjuje na <code>.zip</code> uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.</p>",
|
||||
"shrinkImages": "Smanji slike",
|
||||
"textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka",
|
||||
"codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput <code>.json</code>) ako beleške sa kodom ako nije jasno iz metapodataka",
|
||||
"replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški",
|
||||
"import": "Uvezi",
|
||||
"failed": "Uvoz nije uspeo: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "HTML oznake za uvoz",
|
||||
"description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.",
|
||||
"placeholder": "Unesite HTML oznake, po jednu u svaki red",
|
||||
"reset_button": "Vrati na podrazumevanu listu"
|
||||
},
|
||||
"import-status": "Status uvoza",
|
||||
"in-progress": "Uvoz u toku: {{progress}}",
|
||||
"successful": "Uvoz je uspešno završen."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Uključi belešku",
|
||||
"label_note": "Beleška",
|
||||
"placeholder_search": "pretraži belešku po njenom imenu",
|
||||
"box_size_prompt": "Veličina kutije priložene beleške:",
|
||||
"box_size_small": "mala (~ 10 redova)",
|
||||
"box_size_medium": "srednja (~ 30 redova)",
|
||||
"box_size_full": "puna (kutija prikazuje ceo tekst)",
|
||||
"button_include": "Uključi belešku"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Informativna poruka",
|
||||
"closeButton": "Zatvori",
|
||||
"okButton": "U redu"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...",
|
||||
"search_button": "Pretraga u punom tekstu <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Uvoz za Markdown",
|
||||
"modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz",
|
||||
"import_button": "Uvoz",
|
||||
"import_success": "Markdown sadržaj je učitan u dokument."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Premesti beleške u ...",
|
||||
"notes_to_move": "Beleške za premeštanje",
|
||||
"target_parent_note": "Ciljana nadbeleška",
|
||||
"search_placeholder": "potraži belešku po njenom imenu",
|
||||
"move_button": "Pređi na izabranu belešku",
|
||||
"error_no_path": "Nema putanje za premeštanje.",
|
||||
"move_success_message": "Izabrane beleške su premeštene u "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Promenite gde će se napraviti nova beleška:",
|
||||
"search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)",
|
||||
"modal_title": "Izaberite tip beleške",
|
||||
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
|
||||
"templates": "Šabloni"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Lozinka nije podešena",
|
||||
"body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.",
|
||||
"body2": "Za biste mogli da sačuvate beleške, kliknite <a class=\"open-password-options-button\" href=\"javascript:\">ovde</a> da otvorite dijalog sa Opcijama i podesite svoju lozinku."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Upit",
|
||||
"ok": "U redu <kbd>enter</kbd>",
|
||||
"defaultTitle": "Upit"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Zaštićena sesija",
|
||||
"help_title": "Pomoć za Zaštićene beleške",
|
||||
"close_label": "Zatvori",
|
||||
"form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:",
|
||||
"start_button": "Započni zaštićenu sesiju"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Nedavne promene",
|
||||
"erase_notes_button": "Obriši izabrane beleške odmah",
|
||||
"deleted_notes_message": "Obrisane beleške su uklonjene.",
|
||||
"no_changes_message": "Još uvek nema izmena...",
|
||||
"undelete_link": "poništi brisanje",
|
||||
"confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Revizije beleški",
|
||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||
"delete_all_button": "Obriši sve revizije",
|
||||
"help_title": "Pomoć za Revizije beleški",
|
||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||
"restore_button": "Vrati",
|
||||
"confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.",
|
||||
"delete_button": "Obriši",
|
||||
"confirm_delete": "Da li želite da obrišete ovu reviziju?",
|
||||
"revisions_deleted": "Revizije beleške su obrisane.",
|
||||
"revision_restored": "Revizija beleške je vraćena.",
|
||||
"revision_deleted": "Revizija beleške je obrisana.",
|
||||
"snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.",
|
||||
"maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.",
|
||||
"settings": "Podešavanja revizija beleški",
|
||||
"download_button": "Preuzmi",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Veličina datoteke:",
|
||||
"preview": "Pregled:",
|
||||
"preview_not_available": "Pregled nije dostupan za ovaj tip beleške."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Sortiranje podbeleški po...",
|
||||
"sorting_criteria": "Kriterijum za sortiranje",
|
||||
"title": "naslov",
|
||||
"date_created": "datum kreiranja",
|
||||
"date_modified": "datum izmene",
|
||||
"sorting_direction": "Smer sortiranja",
|
||||
"ascending": "uzlazni",
|
||||
"descending": "silazni",
|
||||
"folders": "Fascikle",
|
||||
"sort_folders_at_top": "sortiraj fascikle na vrh",
|
||||
"natural_sort": "Prirodno sortiranje",
|
||||
"sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.",
|
||||
"natural_sort_language": "Jezik za prirodno sortiranje",
|
||||
"the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.",
|
||||
"sort": "Sortiraj"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Otpremite priloge uz belešku",
|
||||
"choose_files": "Izaberite datoteke",
|
||||
"files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u {{noteTitle}}",
|
||||
"options": "Opcije",
|
||||
"shrink_images": "Smanji slike",
|
||||
"upload": "Otpremi",
|
||||
"tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Naslov detalja atributa",
|
||||
"close_button_title": "Otkaži izmene i zatvori",
|
||||
"attr_is_owned_by": "Atribut je u vlasništvu",
|
||||
"attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte",
|
||||
"name": "Naziv",
|
||||
"value": "Vrednost",
|
||||
"target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.",
|
||||
"target_note": "Ciljna beleška",
|
||||
"promoted_title": "Promovisani atribut je istaknut na belešci.",
|
||||
"promoted": "Promovisan",
|
||||
"promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.",
|
||||
"promoted_alias": "Pseudonim",
|
||||
"multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.",
|
||||
"multiplicity": "Multiplicitet",
|
||||
"single_value": "Jednostruka vrednost",
|
||||
"multi_value": "Višestruka vrednost",
|
||||
"label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.",
|
||||
"label_type": "Tip",
|
||||
"text": "Tekst",
|
||||
"number": "Broj",
|
||||
"boolean": "Boolean",
|
||||
"date": "Datum",
|
||||
"date_time": "Datum i vreme",
|
||||
"time": "Vreme",
|
||||
"url": "URL",
|
||||
"precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.",
|
||||
"precision": "Preciznost",
|
||||
"digits": "cifre",
|
||||
"inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.",
|
||||
"inverse_relation": "Inverzna relacija",
|
||||
"inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.",
|
||||
"inheritable": "Nasledno",
|
||||
"save_and_close": "Sačuvaj i zatvori <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Obriši",
|
||||
"related_notes_title": "Druge beleške sa ovom oznakom",
|
||||
"more_notes": "Još beleški",
|
||||
"label": "Detalji oznake",
|
||||
"label_definition": "Detalji definicije oznake",
|
||||
"relation": "Detalji relacije",
|
||||
"relation_definition": "Detalji definicije relacije",
|
||||
"disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte",
|
||||
"calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.",
|
||||
"archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).",
|
||||
"exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški",
|
||||
"run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n<ul>\n<li>frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.</li>\n<li>mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..</li>\n<li>backendStartup - kada se Trilium backend pokrene</li>\n<li>hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka <code>runAtHour</code> da se označi u kom satu.</li>\n<li>daily - pokreće se jednom dnevno</li>\n</ul>",
|
||||
"run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.",
|
||||
"run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa <code>#run=hourly</code>. Može biti definisano više puta za više pokretanja u toku dana.",
|
||||
"disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.",
|
||||
"sorted": "čuva podbeleške sortirane alfabetski po naslovu",
|
||||
"sort_direction": "Uzlazno (podrazumevano) ili silazno",
|
||||
"sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu",
|
||||
"top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)",
|
||||
"hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci",
|
||||
"read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.",
|
||||
"auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku",
|
||||
"app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.",
|
||||
"app_theme": "označava CSS beleške koje su pune Trilium teme i stoga su dostupne u Trilium podešavanjima.",
|
||||
"app_theme_base": "podesite na „sledeće“, „sledeće-svetlo“ ili „sledeće-tamno“ da biste koristili odgovarajuću TriliumNext temu (automatsku, svetlu ili tamnu) kao osnovu za prilagođenu temu, umesto podrazumevane teme.",
|
||||
"css_class": "vrednost ove oznake se zatim dodaje kao CSS klasa čvoru koji predstavlja datu belešku u stablu. Ovo može biti korisno za napredno temiranje. Može se koristiti u šablonima beleški.",
|
||||
"workspace": "označava ovu belešku kao radni prostor što omogućava lako podizanje",
|
||||
"workspace_icon_class": "definiše CSS klasu ikone okvira koja će se koristiti u kartici kada se podigne na ovoj belešci",
|
||||
"workspace_tab_background_color": "CSS boja korišćena u kartici beleške kada se prebaci na ovu belešku",
|
||||
"workspace_calendar_root": "Definiše koren kalendara za svaki radni prostor",
|
||||
"workspace_template": "Ova beleška će se pojaviti u izboru dostupnih šablona prilikom kreiranja nove beleške, ali samo kada se podigne u radni prostor koji sadrži ovaj šablon",
|
||||
"search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške",
|
||||
"workspace_search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške kada se podignu na nekog pretka ove beleške iz radnog prostora",
|
||||
"inbox": "podrazumevana lokacija u prijemnom sandučetu za nove beleške - kada kreirate belešku pomoću dugmeta „nova beleška“ u bočnoj traci, beleške će biti kreirane kao podbeleške u belešci označenoj sa oznakom <code>#inbox</code>.",
|
||||
"workspace_inbox": "podrazumevana lokacija prijemnog sandučeta za nove beleške kada se prebace na nekog pretka ove beleške iz radnog prostora",
|
||||
"sql_console_home": "podrazmevana lokacija beleški SQL konzole",
|
||||
"bookmark_folder": "beleška sa ovom oznakom će se pojaviti u obeleživačima kao fascikla (omogućavajući pristup njenim podređenim fasciklama)",
|
||||
"share_hidden_from_tree": "ova beleška je skrivena u levom navigacionom stablu, ali je i dalje dostupna preko svoje URL adrese",
|
||||
"share_external_link": "beleška će služiti kao veza ka eksternoj veb stranici u stablu deljenja",
|
||||
"share_alias": "definišite alias pomoću kog će beleška biti dostupna na https://your_trilium_host/share/[your_alias]",
|
||||
"share_omit_default_css": "CSS kod podrazumevane stranice za deljenje će biti izostavljen. Koristite ga kada pravite opsežne promene stila.",
|
||||
"share_root": "obeležava belešku koja se prikazuje na /share korenu.",
|
||||
"share_description": "definišite tekst koji će se dodati HTML meta oznaci za opis",
|
||||
"share_raw": "beleška će biti prikazana u svom sirovom (raw) formatu, bez HTML omotača",
|
||||
"share_disallow_robot_indexing": "zabraniće robotsko indeksiranje ove beleške putem zaglavlja <code>X-Robots-Tag: noindex</code>",
|
||||
"share_credentials": "potrebni su kredencijali za pristup ovoj deljenoj belešci. Očekuje se da vrednost bude u formatu „korisničko ime:lozinka“. Ne zaboravite da ovo označite kao nasledno da bi se primenilo na podbeleške/slike.",
|
||||
"share_index": "beleška sa ovom oznakom će izlistati sve korene deljenih beleški",
|
||||
"display_relations": "imena relacija razdvojenih zarezima koja treba da budu prikazana. Sva ostala će biti skrivena.",
|
||||
"hide_relations": "imena relacija razdvojenih zarezima koja treba da budu skrivena. Sva ostala će biti prikazana.",
|
||||
"title_template": "podrazumevani naslov beleški kreiranih kao deca ove beleške. Vrednost se procenjuje kao JavaScript string \n i stoga se može obogatiti dinamičkim sadržajem putem ubrizganih promenljivih <code>now</code> and <code>parentNote</code>. Primeri:\n \n <ul>\n <li><code>${parentNote.getLabelValue('authorName')}'s literary works</code></li>\n <li><code>Log for ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Pogledati <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">wiki sa detaljima</a>, API dokumentacija za <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> i <a href=\"https://day.js.org/docs/en/display/format\">now</a> za detalje.",
|
||||
"template": "Ova beleška će biti prikazana u izboru dostupnih šablona prilikom pravljenja nove beleške",
|
||||
"toc": "<code>#toc</code> ili <code>#toc=show</code> će pristiliti Sadržaj (Table of Contents) da bude prikazan, <code>#toc=hide</code> prisiliti njegovo sakrivanje. Ako oznaka ne postoji, ponašanje će biti usklađeno sa globalnim podešavanjem",
|
||||
"color": "definiše boju beleške u stablu beleški, linkovima itd. Koristite bilo koju važeću CSS vrednost boje kao što je „crvena“ ili #a13d5f",
|
||||
"keyboard_shortcut": "Definiše prečicu na tastaturi koja će odmah preći na ovu belešku. Primer: „ctrl+alt+e“. Potrebno je ponovno učitavanje frontenda da bi promena stupila na snagu.",
|
||||
"keep_current_hoisting": "Otvaranje ove veze neće promeniti podizanje čak i ako beleška nije prikazana u trenutno podignutom podstablu.",
|
||||
"execute_button": "Naslov dugmeta koje će izvršiti trenutnu belešku sa kodom",
|
||||
"execute_description": "Duži opis trenutne beleške sa kodom prikazan je zajedno sa dugmetom za izvršavanje",
|
||||
"exclude_from_note_map": "Beleške sa ovom oznakom biće skrivene sa mape beleški",
|
||||
"new_notes_on_top": "Nove beleške će biti napravljene na vrhu matične beleške, a ne na dnu.",
|
||||
"hide_highlight_widget": "Sakrij vidžet sa listom istaknutih",
|
||||
"run_on_note_creation": "izvršava se kada se beleška napravi na serverskoj strani. Koristite ovu relaciju ako želite da pokrenete skriptu za sve beleške napravljene u okviru određenog podstabla. U tom slučaju, kreirajte je na korenu beleške podstabla i učinite je naslednom. Nova beleška napravljena unutar podstabla (bilo koje dubine) pokrenuće skriptu.",
|
||||
"run_on_child_note_creation": "izvršava se kada se napravi nova beleška ispod beleške gde je ova relacija definisana",
|
||||
"run_on_note_title_change": "izvršava se kada se promeni naslov beleške (uključuje i pravljenje beleške)",
|
||||
"run_on_note_content_change": "izvršava se kada se promeni sadržaj beleške (uključuje i pravljenje beleške).",
|
||||
"run_on_note_change": "izvršava se kada se promeni beleška (uključuje i pravljenje beleške). Ne uključuje promene sadržaja",
|
||||
"icon_class": "vrednost ove oznake se dodaje kao CSS klasa ikoni na stablu što može pomoći u vizuelnom razlikovanju beleški u stablu. Primer može biti bx bx-home - ikone su preuzete iz boxicons. Može se koristiti u šablonima beleški.",
|
||||
"page_size": "broj stavki po stranici u listi beleški",
|
||||
"custom_request_handler": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"custom_resource_provider": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"widget": "označava ovu belešku kao prilagođeni vidžet koji će biti dodat u stablo komponenti Trilijuma",
|
||||
"run_on_note_deletion": "izvršava se kada se beleška briše",
|
||||
"run_on_branch_creation": "izvršava se kada se pravi grana. Grana je veza između matične i podređene beleške i pravi se npr. prilikom kloniranja ili premeštanja beleške.",
|
||||
"run_on_branch_change": "izvršava se kada se grana ažurira.",
|
||||
"run_on_branch_deletion": "izvršava se kada se grana briše. Grana je veza između nadređene beleške i podređene beleške i briše se npr. prilikom premeštanja beleške (stara grana/veza se briše).",
|
||||
"run_on_attribute_creation": "izvršava se kada se pravi novi atribut za belešku koji definiše ovu relaciju",
|
||||
"run_on_attribute_change": " izvršava se kada se promeni atribut beleške koja definiše ovu relaciju. Ovo se pokreće i kada se atribut obriše",
|
||||
"relation_template": "atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete, sadržaj i podstablo beleške će biti dodati instanci beleške ako je prazna. Pogledajte dokumentaciju za detalje.",
|
||||
"inherit": "Atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete. Pogledajte relaciju šablona za sličan koncept. Pogledajte nasleđivanje atributa u dokumentaciji.",
|
||||
"render_note": "Beleške tipa „render HTML note“ će biti prikazane korišćenjem beleške za kod (HTML ili skripte) i potrebno je pomoću ove relacije ukazati na to koja beleška treba da se prikaže",
|
||||
"widget_relation": "meta ove relacije će biti izvršena i prikazana kao vidžet u bočnoj traci",
|
||||
"share_css": "CSS napomena koja će biti ubrizgana na stranicu za deljenje. CSS napomena mora biti i u deljenom podstablu. Razmotrite i korišćenje „share_hidden_from_tree“ i „share_omit_default_css“.",
|
||||
"share_js": "JavaScript beleška koja će biti ubrizgana na stranicu za deljenje. JS beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_template": "Ugrađena JavaScript beleška koja će se koristiti kao šablon za prikazivanje deljene beleške. U slučaju neuspeha vraća se na podrazumevani šablon. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_favicon": "Favicon beleška koju treba postaviti na deljenu stranicu. Obično je potrebno da je podesite da deli koren i učinite je naslednom. Favicon beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"is_owned_by_note": "je u vlasništvu beleške",
|
||||
"other_notes_with_name": "Ostale beleške sa {{attributeType}} nazivom „{{attributeName}}“",
|
||||
"and_more": "... i još {{count}}.",
|
||||
"print_landscape": "Prilikom izvoza u PDF, menja orijentaciju stranice u pejzažnu umesto uspravne.",
|
||||
"print_page_size": "Prilikom izvoza u PDF, menja veličinu stranice. Podržane vrednosti: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Boja"
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} beleška stavljena u red za indeksiranje",
|
||||
"n_notes_queued_1": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"n_notes_queued_2": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"notes_indexed_0": "{{ count }} beleška je indeksirana",
|
||||
"notes_indexed_1": "{{ count }} beleški je indeksirano",
|
||||
"notes_indexed_2": "{{ count }} beleški je indeksirano"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Da biste dodali oznaku, samo unesite npr. <code>#rock</code> ili ako želite da dodate i vrednost, onda npr. <code>#year = 2020</code>",
|
||||
"help_text_body2": "Za relaciju, unesite <code>~author = @</code> što bi trebalo da otvori automatsko dovršavanje gde možete potražiti željenu belešku.",
|
||||
"help_text_body3": "Alternativno, možete dodati oznaku i relaciju pomoću dugmeta <code>+</code> sa desne strane.",
|
||||
"save_attributes": "Sačuvaj atribute <enter>",
|
||||
"add_a_new_attribute": "Dodajte novi atribut",
|
||||
"add_new_label": "Dodajte novu oznaku <kbd data-command=\"addNewLabel\"></kbd>",
|
||||
"add_new_relation": "Dodajte novu relaciju <kbd data-command=\"addNewRelation\"></kbd>",
|
||||
"add_new_label_definition": "Dodajte novu definiciju oznake",
|
||||
"add_new_relation_definition": "Dodajte novu definiciju relacije",
|
||||
"placeholder": "Ovde unesite oznake i relacije"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Ukloni ovu radnju pretrage"
|
||||
},
|
||||
"execute_script": {
|
||||
"execute_script": "Izvrši skriptu",
|
||||
"help_text": "Možete izvršiti jednostavne skripte na podudarnim beleškama.",
|
||||
"example_1": "Na primer, da biste dodali string u naslov beleške, koristite ovu malu skriptu:",
|
||||
"example_2": "Složeniji primer bi bio brisanje svih atributa podudarnih beleški:"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Dodaj oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crta i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "za vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama:",
|
||||
"help_text_item1": "dodajte datu oznaku ako beleška još uvek nema jednu",
|
||||
"help_text_item2": "ili izmenite vrednost postojeće oznake",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Obriši oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Preimenuj oznaku",
|
||||
"rename_label_from": "Preimenuj oznaku iz",
|
||||
"old_name_placeholder": "stari naziv",
|
||||
"to": "U",
|
||||
"new_name_placeholder": "novi naziv",
|
||||
"name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"update_label_value": {
|
||||
"update_label_value": "Ažuriraj vrednost oznake",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "u vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama, promenite vrednost postojeće oznake.",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "Obriši belešku",
|
||||
"delete_matched_notes": "Obriši podudarne beleške",
|
||||
"delete_matched_notes_description": "Ovo će obrisati podudarne beleške.",
|
||||
"undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene."
|
||||
}
|
||||
}
|
||||
26
apps/client/src/translations/tr/translation.json
Normal file
26
apps/client/src/translations/tr/translation.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"about": {
|
||||
"homepage": "Giriş sayfası:",
|
||||
"app_version": "Uygulama versiyonu:",
|
||||
"db_version": "Veritabanı versiyonu:"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Kaydet"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"export": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"import": {
|
||||
"chooseImportFile": "İçe aktarım dosyası",
|
||||
"importDescription": "Seçilen dosya(lar) alt not olarak içe aktarılacaktır"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Kapat"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Kapat"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
69
apps/client/src/translations/vi/translation.json
Normal file
69
apps/client/src/translations/vi/translation.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"about": {
|
||||
"homepage": "Trang chủ:",
|
||||
"title": "Về Trilium Notes"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Lưu"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Đóng",
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"export": {
|
||||
"close": "Đóng"
|
||||
},
|
||||
"help": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Lỗi nghiêm trọng"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Đóng"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Chuyển ghi chép tới..."
|
||||
},
|
||||
"prompt": {
|
||||
"ok": "OK"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Đóng"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Khôi phục",
|
||||
"delete_button": "Xoá"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Tên",
|
||||
"value": "Giá trị",
|
||||
"text": "Văn bản",
|
||||
"number": "Số",
|
||||
"delete": "Xoá"
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "Đổi tên ghi chép"
|
||||
}
|
||||
}
|
||||
5
apps/client/src/types-assets.d.ts
vendored
5
apps/client/src/types-assets.d.ts
vendored
@@ -3,6 +3,11 @@ declare module "*.png" {
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*.json" {
|
||||
var content: any;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*?url" {
|
||||
var path: string;
|
||||
export default path;
|
||||
|
||||
10
apps/client/src/types.d.ts
vendored
10
apps/client/src/types.d.ts
vendored
@@ -97,16 +97,6 @@ declare global {
|
||||
setNote(noteId: string);
|
||||
}
|
||||
|
||||
interface JQueryStatic {
|
||||
hotkeys: {
|
||||
options: {
|
||||
filterInputAcceptingElements: boolean;
|
||||
filterContentEditable: boolean;
|
||||
filterTextInputs: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var logError: (message: string, e?: Error | string) => void;
|
||||
var logInfo: (message: string) => void;
|
||||
var glob: CustomGlobals;
|
||||
|
||||
@@ -9,6 +9,9 @@ import contentRenderer from "../services/content_renderer.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import mediaViewer from "../services/media_viewer.js";
|
||||
import type { MediaItem } from "../services/media_viewer.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attachment-detail-widget">
|
||||
@@ -65,6 +68,12 @@ const TPL = /*html*/`
|
||||
|
||||
.attachment-content-wrapper img {
|
||||
margin: 10px;
|
||||
cursor: zoom-in;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper img:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
|
||||
@@ -77,6 +86,24 @@ const TPL = /*html*/`
|
||||
max-width: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-lightbox-hint {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper:hover .attachment-lightbox-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
|
||||
filter: contrast(10%);
|
||||
@@ -88,7 +115,9 @@ const TPL = /*html*/`
|
||||
<div class="attachment-actions-container"></div>
|
||||
<h4 class="attachment-title"></h4>
|
||||
<div class="attachment-details"></div>
|
||||
<div style="flex: 1 1;"></div>
|
||||
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note">
|
||||
<span class="bx bx-arrow-back"></span> Back to Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
|
||||
@@ -124,6 +153,14 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
||||
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
|
||||
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
|
||||
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
|
||||
|
||||
// Setup back to note button (only show in full detail mode)
|
||||
if (this.isFullDetail) {
|
||||
const $backBtn = this.$wrapper.find('.back-to-note-btn');
|
||||
$backBtn.on('click', () => this.handleBackToNote());
|
||||
} else {
|
||||
this.$wrapper.find('.back-to-note-btn').hide();
|
||||
}
|
||||
|
||||
if (!this.isFullDetail) {
|
||||
const $link = await linkService.createLink(this.attachment.ownerId, {
|
||||
@@ -170,7 +207,92 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
||||
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
|
||||
|
||||
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
|
||||
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
|
||||
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper");
|
||||
$contentWrapper.append($renderedContent);
|
||||
|
||||
// Add PhotoSwipe integration for image attachments
|
||||
if (this.attachment.role === 'image') {
|
||||
this.setupPhotoSwipeIntegration($contentWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
setupPhotoSwipeIntegration($contentWrapper: JQuery<HTMLElement>) {
|
||||
// Add lightbox hint
|
||||
const $hint = $('<div class="attachment-lightbox-hint">Click to view in lightbox</div>');
|
||||
$contentWrapper.css('position', 'relative').append($hint);
|
||||
|
||||
// Find the image element
|
||||
const $img = $contentWrapper.find('img');
|
||||
if (!$img.length) return;
|
||||
|
||||
// Setup click handler for lightbox with namespace for proper cleanup
|
||||
$img.off('click.photoswipe').on('click.photoswipe', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const item: MediaItem = {
|
||||
src: $img.attr('src') || '',
|
||||
alt: this.attachment.title,
|
||||
title: this.attachment.title,
|
||||
noteId: this.attachment.ownerId,
|
||||
element: $img[0] as HTMLElement
|
||||
};
|
||||
|
||||
// Try to get actual dimensions
|
||||
const imgElement = $img[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
|
||||
};
|
||||
}
|
||||
}, {
|
||||
onOpen: () => {
|
||||
console.log('Attachment image opened in lightbox');
|
||||
},
|
||||
onClose: () => {
|
||||
// Check if we're in attachment detail view and reset viewScope if needed
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext?.viewScope?.viewMode === 'attachments' &&
|
||||
activeContext?.viewScope?.attachmentId === this.attachment.attachmentId) {
|
||||
// Reset to normal note view when closing lightbox from attachment detail
|
||||
activeContext.setNote(this.attachment.ownerId, {
|
||||
viewScope: { viewMode: 'default' }
|
||||
});
|
||||
}
|
||||
// Restore focus to the image
|
||||
$img.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add keyboard support
|
||||
$img.attr('tabindex', '0')
|
||||
.attr('role', 'button')
|
||||
.attr('aria-label', 'Click to view in lightbox');
|
||||
|
||||
// Use namespaced event for proper cleanup
|
||||
$img.off('keydown.photoswipe').on('keydown.photoswipe', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
$img.trigger('click');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async copyAttachmentLinkToClipboard() {
|
||||
@@ -204,4 +326,43 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleBackToNote() {
|
||||
try {
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
console.warn('No active context available for navigation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.attachment.ownerId) {
|
||||
console.error('Cannot navigate back: no owner ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
await activeContext.setNote(this.attachment.ownerId, {
|
||||
viewScope: { viewMode: 'default' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate back to note:', error);
|
||||
toastService.showError('Failed to navigate back to note');
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Remove all event handlers before cleanup
|
||||
const $contentWrapper = this.$wrapper?.find('.attachment-content-wrapper');
|
||||
if ($contentWrapper?.length) {
|
||||
const $img = $contentWrapper.find('img');
|
||||
if ($img.length) {
|
||||
// Remove namespaced event handlers
|
||||
$img.off('.photoswipe');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove back button handler
|
||||
this.$wrapper?.find('.back-to-note-btn').off('click');
|
||||
|
||||
super.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ const TPL = /*html*/`
|
||||
<option value="datetime">${t("attribute_detail.date_time")}</option>
|
||||
<option value="time">${t("attribute_detail.time")}</option>
|
||||
<option value="url">${t("attribute_detail.url")}</option>
|
||||
<option value="color">${t("attribute_detail.color_type")}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
54
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
54
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import AbstractBulkAction from "./abstract_bulk_action";
|
||||
|
||||
interface BulkActionProps {
|
||||
label: string | ComponentChildren;
|
||||
children?: ComponentChildren;
|
||||
helpText?: ComponentChildren;
|
||||
bulkAction: AbstractBulkAction;
|
||||
}
|
||||
|
||||
// Define styles as constants to prevent recreation
|
||||
const flexContainerStyle = { display: "flex", alignItems: "center" } as const;
|
||||
const labelStyle = { marginRight: "10px" } as const;
|
||||
const textStyle = { marginRight: "10px", marginLeft: "10px" } as const;
|
||||
|
||||
const BulkAction = memo(({ label, children, helpText, bulkAction }: BulkActionProps) => {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<div style={flexContainerStyle}>
|
||||
<div style={labelStyle} className="text-nowrap">{label}</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
<td className="button-column">
|
||||
{helpText && <div className="dropdown help-dropdown">
|
||||
<span className="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div className="dropdown-menu dropdown-menu-right p-4">
|
||||
{helpText}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<span
|
||||
className="bx bx-x icon-action action-conf-del"
|
||||
onClick={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
export default BulkAction;
|
||||
|
||||
export const BulkActionText = memo(({ text }: { text: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={textStyle}
|
||||
className="text-nowrap">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FAttribute from "../../entities/fattribute.js";
|
||||
import { VNode } from "preact";
|
||||
|
||||
interface ActionDefinition {
|
||||
export interface ActionDefinition {
|
||||
script: string;
|
||||
relationName: string;
|
||||
targetNoteId: string;
|
||||
@@ -27,26 +26,9 @@ export default abstract class AbstractBulkAction {
|
||||
this.actionDef = actionDef;
|
||||
}
|
||||
|
||||
render() {
|
||||
try {
|
||||
const $rendered = this.doRender();
|
||||
|
||||
$rendered
|
||||
.find(".action-conf-del")
|
||||
.on("click", () => this.deleteAction())
|
||||
.attr("title", t("abstract_bulk_action.remove_this_search_action"));
|
||||
|
||||
utils.initHelpDropdown($rendered);
|
||||
|
||||
return $rendered;
|
||||
} catch (e: any) {
|
||||
logError(`Failed rendering search action: ${JSON.stringify(this.attribute.dto)} with error: ${e.message} ${e.stack}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// to be overridden
|
||||
abstract doRender(): JQuery<HTMLElement>;
|
||||
abstract doRender(): VNode;
|
||||
|
||||
static get actionName() {
|
||||
return "";
|
||||
}
|
||||
@@ -66,9 +48,6 @@ export default abstract class AbstractBulkAction {
|
||||
|
||||
async deleteAction() {
|
||||
await server.remove(`notes/${this.attribute.noteId}/attributes/${this.attribute.attributeId}`);
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
//await this.triggerCommand('refreshSearchDefinition');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user