Compare commits

..

107 Commits

Author SHA1 Message Date
Elian Doran
2e0f606a7a chore(release): prepare for v0.98.0 2025-08-17 22:46:16 +03:00
Elian Doran
87878dd6a7 Translations update from Hosted Weblate (#6679) 2025-08-17 22:20:39 +03:00
morteza rahvard
5296e073cc Translated using Weblate (Persian)
Currently translated at 0.7% (12 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fa/
2025-08-17 19:17:36 +00:00
acwr47
7bfb7d6f6e Translated using Weblate (Japanese)
Currently translated at 65.4% (1014 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-17 19:17:35 +00:00
Elian Doran
b5069cc7c2 chore(call_to_action): rephrase 2025-08-17 22:17:21 +03:00
Elian Doran
3b6791f51a chore(call_to_action): disable background effects for now 2025-08-17 21:23:22 +03:00
Elian Doran
0b0be77e02 chore(deps): update dependency @sveltejs/kit to v2.31.1 (#6676) 2025-08-17 08:23:45 +03:00
renovate[bot]
60db10559e chore(deps): update dependency @sveltejs/kit to v2.31.1 2025-08-17 02:31:17 +00:00
Elian Doran
76b066ba4a Translations update from Hosted Weblate (#6673) 2025-08-16 23:27:47 +03:00
ali mohammadi
a28db32369 Translated using Weblate (Persian)
Currently translated at 1.5% (6 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fa/
2025-08-16 20:02:07 +00:00
ali mohammadi
2523632391 Translated using Weblate (Persian)
Currently translated at 0.1% (3 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fa/
2025-08-16 20:02:05 +00:00
neketos851
53548c356a Translated using Weblate (Ukrainian)
Currently translated at 2.1% (8 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/uk/
2025-08-16 20:02:03 +00:00
neketos851
565904ff5d Translated using Weblate (Ukrainian)
Currently translated at 3.1% (49 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/uk/
2025-08-16 20:02:01 +00:00
acwr47
e0c5545f8c Translated using Weblate (Japanese)
Currently translated at 65.2% (1012 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-16 20:01:59 +00:00
Aristide Bauchart
bc21285289 Translated using Weblate (French)
Currently translated at 81.7% (309 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fr/
2025-08-16 20:01:57 +00:00
Elian Doran
318d504fad chore(deps): update dependency @types/node to v22.17.2 (#6665) 2025-08-16 08:55:02 +03:00
Elian Doran
fd5038148c Merge branch 'main' into renovate/node-22.x 2025-08-16 08:55:00 +03:00
renovate[bot]
693ca9291e chore(deps): update dependency @types/node to v22.17.2 2025-08-16 05:45:48 +00:00
Elian Doran
cfd8afc226 chore(deps): update dependency @sveltejs/kit to v2.31.0 (#6666) 2025-08-16 08:44:44 +03:00
Elian Doran
3e52ca7600 chore(deps): update dependency electron to v37.3.0 (#6667) 2025-08-16 08:44:09 +03:00
renovate[bot]
482522e802 chore(deps): update dependency electron to v37.3.0 2025-08-16 02:10:14 +00:00
renovate[bot]
8b5b6a01c6 chore(deps): update dependency @sveltejs/kit to v2.31.0 2025-08-16 02:09:37 +00:00
Elian Doran
e4403dd316 Translations update from Hosted Weblate (#6664) 2025-08-15 23:04:33 +03:00
ali mohammadi
3f267fe6c9 Added translation using Weblate (Persian) 2025-08-15 21:31:45 +02:00
ali mohammadi
3229471485 Added translation using Weblate (Persian) 2025-08-15 21:31:44 +02:00
neketos851
62bac1adf9 Translated using Weblate (Ukrainian)
Currently translated at 0.7% (3 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/uk/
2025-08-15 21:31:43 +02:00
neketos851
82becfd52a Translated using Weblate (Ukrainian)
Currently translated at 0.5% (9 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/uk/
2025-08-15 21:31:43 +02:00
Elian Doran
92f035545b Translations update from Hosted Weblate (#6663) 2025-08-15 21:49:49 +03:00
neketos851
74d8ea7dcb Added translation using Weblate (Ukrainian) 2025-08-15 20:45:02 +02:00
neketos851
ac3f087279 Added translation using Weblate (Ukrainian) 2025-08-15 20:45:02 +02:00
VortexP
1cc4eb98c1 Translated using Weblate (Finnish)
Currently translated at 1.5% (6 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fi/
2025-08-15 20:45:01 +02:00
VortexP
e99bdf8f24 Translated using Weblate (Finnish)
Currently translated at 6.0% (94 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fi/
2025-08-15 20:45:01 +02:00
acwr47
b4f521a141 Translated using Weblate (Japanese)
Currently translated at 60.8% (943 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-15 20:45:01 +02:00
VortexP
1e23bc09f1 Translated using Weblate (Finnish)
Currently translated at 1.3% (5 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fi/
2025-08-15 16:07:51 +00:00
VortexP
e3ec90405d Translated using Weblate (Finnish)
Currently translated at 1.6% (25 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fi/
2025-08-15 16:07:51 +00:00
acwr47
41c87794a4 Translated using Weblate (Japanese)
Currently translated at 60.7% (942 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-15 16:07:50 +00:00
VortexP
e62d2d4fda Added translation using Weblate (Finnish) 2025-08-15 16:07:50 +00:00
VortexP
93adaa0f52 Added translation using Weblate (Finnish) 2025-08-15 16:07:49 +00:00
Excal
263a5d2067 Translated using Weblate (Russian)
Currently translated at 7.9% (30 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ru/
2025-08-15 16:07:48 +00:00
acwr47
f0a5005794 Translated using Weblate (Japanese)
Currently translated at 60.3% (936 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-15 16:07:48 +00:00
Elian Doran
577457c8ab fix(docs): Update links to Trilium repo files in advanced config docs (#6662) 2025-08-15 19:07:39 +03:00
Jon Fuller
c0c450c444 fix(docs): Update links to Trilium repo files in advanced config docs 2025-08-15 08:39:40 -07:00
Elian Doran
1e1e0b0f51 Fix (Update): No update notification in the global menu (#6657) 2025-08-15 16:56:12 +03:00
Elian Doran
a19204a1d5 Translations update from Hosted Weblate (#6661) 2025-08-15 16:55:35 +03:00
Flowerlywind
1d139bfdfe Translated using Weblate (Vietnamese)
Currently translated at 2.1% (33 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/vi/
2025-08-15 14:02:12 +02:00
Excal
75072decec Translated using Weblate (Russian)
Currently translated at 1.8% (7 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ru/
2025-08-15 14:02:09 +02:00
Francis C
0cf2ad6901 Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-15 14:02:08 +02:00
acwr47
ccbd57a0c0 Translated using Weblate (Japanese)
Currently translated at 54.5% (845 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-15 14:02:07 +02:00
Francis C
92e6c8c445 Translated using Weblate (Japanese)
Currently translated at 54.5% (845 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-15 14:02:06 +02:00
Francis C
1e966f1d59 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (378 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
2025-08-15 14:02:05 +02:00
Francis C
6872c2194e Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2025-08-15 14:02:03 +02:00
Bruno MARGUERIN
5b6a0216db Translated using Weblate (French)
Currently translated at 71.1% (269 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fr/
2025-08-15 14:02:01 +02:00
Elian Doran
e9a7194cd6 Translated using Weblate (Romanian)
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ro/
2025-08-15 14:01:59 +02:00
Bruno MARGUERIN
26898b9122 Translated using Weblate (French)
Currently translated at 82.3% (1277 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fr/
2025-08-15 14:01:58 +02:00
SiriusXT
ab1d8594ea Fix (Update): No update notification in the global menu 2025-08-15 16:04:59 +08:00
Elian Doran
1a15782686 fix(deps): update dependency i18next to v25.3.6 (#6652) 2025-08-15 09:05:12 +03:00
Elian Doran
3bd0aeef77 chore(deps): update svelte monorepo (#6654) 2025-08-15 09:04:48 +03:00
Elian Doran
b463baedd2 chore(deps): update dependency tsx to v4.20.4 (#6632) 2025-08-15 09:04:22 +03:00
Elian Doran
ae77c41dab chore(deps): update tailwindcss monorepo to v4.1.12 (#6651) 2025-08-15 09:03:58 +03:00
Elian Doran
807d909acd chore(deps): update dependency @anthropic-ai/sdk to v0.60.0 (#6653) 2025-08-15 09:03:36 +03:00
Elian Doran
fa4f5f526e chore(deps): update dependency turndown to v7.2.1 (#6650) 2025-08-15 09:03:13 +03:00
renovate[bot]
edff43cdb3 chore(deps): update dependency tsx to v4.20.4 2025-08-15 05:51:01 +00:00
renovate[bot]
46fe45528c chore(deps): update svelte monorepo 2025-08-15 02:17:16 +00:00
renovate[bot]
b4b53da6a4 chore(deps): update dependency @anthropic-ai/sdk to v0.60.0 2025-08-15 02:16:43 +00:00
renovate[bot]
41fd270080 fix(deps): update dependency i18next to v25.3.6 2025-08-15 02:16:06 +00:00
renovate[bot]
410bb3cdca chore(deps): update tailwindcss monorepo to v4.1.12 2025-08-15 02:15:22 +00:00
renovate[bot]
bc6fc24fbd chore(deps): update dependency turndown to v7.2.1 2025-08-15 02:14:46 +00:00
Jon Fuller
f8e20a1405 Update README.md (#6648) 2025-08-14 12:31:35 -07:00
MeIchthys
558ae1a2ea Update README.md
- Update links to point to new TriliumNext/Trilium repo
- Update a couple broken links
2025-08-14 14:59:08 -04:00
Elian Doran
1dfcf960d3 fix(client): missing calendar view language support 2025-08-14 15:20:08 +03:00
Elian Doran
9bdc51a3fb feat(i18n): add Japanese language 2025-08-14 14:51:57 +03:00
Elian Doran
dbf3bcfacf Merge remote-tracking branch 'weblate/main' 2025-08-14 14:34:30 +03:00
Elian Doran
3d5b269315 chore(docs): fix file 2025-08-14 14:23:37 +03:00
Elian Doran
48f97da9cc chore(forge/rpm): rename key properly 2025-08-14 14:10:33 +03:00
Elian Doran
9c954fbd81 Create CNAME 2025-08-14 13:53:25 +03:00
Elian Doran
c6bd41654f chore(forge/rpm): add public key 2025-08-14 13:40:23 +03:00
Francis C
d65a74bb23 Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 12:30:37 +02:00
acwr47
ff08bca042 Translated using Weblate (Japanese)
Currently translated at 41.6% (646 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 12:30:36 +02:00
Francis C
a5d3d2e3b4 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2025-08-14 12:30:35 +02:00
Bruno MARGUERIN
496a0667ee Translated using Weblate (French)
Currently translated at 71.1% (269 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fr/
2025-08-14 12:30:34 +02:00
Bruno MARGUERIN
9be688b667 Translated using Weblate (French)
Currently translated at 80.7% (1252 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fr/
2025-08-14 12:30:33 +02:00
Elian Doran
f3d9008c61 feat(forge): rpm signing (#6646) 2025-08-14 13:30:26 +03:00
Elian Doran
649a43c978 fix(forge): RPM signing not done on the right file 2025-08-14 12:45:18 +03:00
Elian Doran
50568704ca feat(forge): minor improvements to RPM signing 2025-08-14 12:40:19 +03:00
Elian Doran
b66b4dec83 feat(forge): proper rpm signing 2025-08-14 12:04:12 +03:00
Francis C
8d0e807435 Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 09:02:31 +00:00
acwr47
bf05ed7caf Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 09:02:29 +00:00
Elian Doran
b5080eff00 Translated using Weblate (Russian)
Currently translated at 55.6% (863 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ru/
2025-08-14 09:02:28 +00:00
Francis C
c474769dd6 Translated using Weblate (Japanese)
Currently translated at 35.4% (550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 09:02:27 +00:00
acwr47
a6ae01da0b Translated using Weblate (Japanese)
Currently translated at 35.4% (550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 09:02:25 +00:00
Francis C
2bf4c44dbf Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (378 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
2025-08-14 09:02:24 +00:00
Francis C
5ca0fbba13 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2025-08-14 09:02:22 +00:00
Elian Doran
4cd84b2019 Translated using Weblate (Romanian)
Currently translated at 99.0% (1536 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ro/
2025-08-14 09:02:19 +00:00
Marcelo Popper Costa
c502a45cf5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 22.3% (346 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/pt_BR/
2025-08-14 09:02:17 +00:00
Francis C
9e66914306 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hans/
2025-08-14 09:02:15 +00:00
Elian Doran
d33d27ee82 feat(forge): validate rpm signing 2025-08-14 11:45:59 +03:00
Elian Doran
e2b13573ae feat(forge): rpm signing 2025-08-14 10:43:38 +03:00
Elian Doran
ec74f5f1de feat(logs): provide an option to keep all logs (#6644) 2025-08-14 08:51:46 +03:00
Elian Doran
5dee56debc Add Traditional Chinese translation for README file & fix Docker Hub URL (#6645) 2025-08-14 08:43:08 +03:00
Francis C.
5623fc992d Update README-ZH_TW.md (tiny fix) 2025-08-14 12:04:45 +08:00
Francis C.
1d28bfc570 Update README-ZH_TW.md (tiny fix) 2025-08-14 11:25:53 +08:00
Francis C.
084327e973 Revise some words for Simplified Chinese translation 2025-08-14 11:20:32 +08:00
Francis C.
b2885efdc1 Update README-ZH_CN.md 2025-08-14 10:56:54 +08:00
Francis C.
b65a75f138 fix relative path for URLs 2025-08-14 10:27:41 +08:00
Francis C.
0ee7f50bb4 Move readme to docs folder 2025-08-14 10:15:03 +08:00
Francis C.
02ce21bc18 Add readme file translation for Traditional Chinese & fix Docker Hub URL 2025-08-14 10:12:14 +08:00
Romain DEP.
3ba487bb00 feat(logs): provide an option to keep all logs 2025-08-13 23:35:31 +02:00
83 changed files with 8895 additions and 20151 deletions

View File

@@ -162,3 +162,25 @@ runs:
echo "Found ZIP: $zip_file" echo "Found ZIP: $zip_file"
echo "Note: ZIP files are not code signed, but their contents should be" echo "Note: ZIP files are not code signed, but their contents should be"
fi fi
- name: Sign the RPM
if: inputs.os == 'linux'
shell: ${{ inputs.shell }}
run: |
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import
# Import the key into RPM for verification
gpg --export -a > pubkey
rpm --import pubkey
rm pubkey
# Sign the RPM
rpm_file=$(find ./apps/desktop/upload -name "*.rpm" -print -quit)
rpmsign --define "_gpg_name Trilium Notes Signing Key <triliumnotes@outlook.com>" --addsign "$rpm_file"
rpm -Kv "$rpm_file"
# Validate code signing
if ! rpm -K "$rpm_file" | grep -q "digests signatures OK"; then
echo .rpm file not signed
exit 1
fi

View File

@@ -76,6 +76,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }} WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release - name: Publish release
uses: softprops/action-gh-release@v2.3.2 uses: softprops/action-gh-release@v2.3.2
@@ -97,7 +98,7 @@ jobs:
path: apps/desktop/upload path: apps/desktop/upload
nightly-server: nightly-server:
if: github.repository == 'TriliumNext/Trilium' if: github.repository == 'TriliumNext/Trilium'
name: Deploy server nightly name: Deploy server nightly
strategy: strategy:
fail-fast: false fail-fast: false

View File

@@ -58,6 +58,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }} WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Upload the artifact - name: Upload the artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -1,11 +1,11 @@
# Trilium Notes # Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran) ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes) ![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/trilium)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/) [![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md) [English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases. Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
@@ -46,15 +46,15 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more. - [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more. - [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
## ⚠️ Why TriliumNext? ## Why TriliumNext?
[The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620). The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
### Migrating from Trilium? ### ⬆️Migrating from Zadam/Trilium?
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Simply [install TriliumNext/Notes](#-installation) as usual and it will use your existing database. There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented. Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
## 📖 Documentation ## 📖 Documentation
@@ -75,8 +75,8 @@ Feel free to join our official conversations. We would love to hear what feature
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.) - [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join) - The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For asynchronous discussions.) - [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
- [Github Issues](https://github.com/TriliumNext/Notes/issues) (For bug reports and feature requests.) - [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
## 🏗 Installation ## 🏗 Installation
@@ -104,13 +104,15 @@ Currently only the latest versions of Chrome & Firefox are supported (and tested
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below). To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid). Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid). See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
See issue https://github.com/TriliumNext/Notes/issues/72 for more information on mobile app support. If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
### Server ### Server
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation). To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
## 💻 Contribute ## 💻 Contribute
@@ -152,11 +154,11 @@ pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32 pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
``` ```
For more details, see the [development docs](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md). For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
### Developer Documentation ### Developer Documentation
Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above. Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
## 👏 Shoutouts ## 👏 Shoutouts
@@ -168,7 +170,7 @@ Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide
## 🤝 Support ## 🤝 Support
Support for the TriliumNext organization will be possible in the near future. For now, you can: Support for the TriliumNext organization will be possible in the near future. For now, you can:
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors))) for a full list) - Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/trilium/graphs/contributors))) for a full list)
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2). - Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).

View File

@@ -38,7 +38,7 @@
"@playwright/test": "1.54.2", "@playwright/test": "1.54.2",
"@stylistic/eslint-plugin": "5.2.3", "@stylistic/eslint-plugin": "5.2.3",
"@types/express": "5.0.3", "@types/express": "5.0.3",
"@types/node": "22.17.1", "@types/node": "22.17.2",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.2.4", "@vitest/coverage-v8": "3.2.4",
"eslint": "9.33.0", "eslint": "9.33.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@triliumnext/client", "name": "@triliumnext/client",
"version": "0.97.2", "version": "0.98.0",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)", "description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true, "private": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -36,7 +36,7 @@
"draggabilly": "3.0.0", "draggabilly": "3.0.0",
"force-graph": "1.50.1", "force-graph": "1.50.1",
"globals": "16.3.0", "globals": "16.3.0",
"i18next": "25.3.4", "i18next": "25.3.6",
"i18next-http-backend": "3.0.2", "i18next-http-backend": "3.0.2",
"jquery": "3.7.1", "jquery": "3.7.1",
"jquery.fancytree": "2.38.5", "jquery.fancytree": "2.38.5",
@@ -55,8 +55,7 @@
"split.js": "1.6.5", "split.js": "1.6.5",
"svg-pan-zoom": "3.6.2", "svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1", "tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4", "vanilla-js-wheel-zoom": "9.0.4"
"photoswipe": "^5.4.4"
}, },
"devDependencies": { "devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0", "@ckeditor/ckeditor5-inspector": "5.0.0",

View File

@@ -13,8 +13,6 @@ import type ElectronRemote from "@electron/remote";
import type Electron from "electron"; import type Electron from "electron";
import "./stylesheets/bootstrap.scss"; import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css"; import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "./styles/gallery.css";
import "autocomplete.js/index_jquery.js"; import "autocomplete.js/index_jquery.js";
await appContext.earlyInit(); await appContext.earlyInit();

View File

@@ -2,8 +2,6 @@ import { t } from "../services/i18n.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import contextMenu from "./context_menu.js"; import contextMenu from "./context_menu.js";
import imageService from "../services/image.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"; const PROP_NAME = "imageContextMenuInstalled";
@@ -20,12 +18,6 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: [ items: [
{
title: "View in Lightbox",
command: "viewInLightbox",
uiIcon: "bx bx-expand",
enabled: true
},
{ {
title: t("image_context_menu.copy_reference_to_clipboard"), title: t("image_context_menu.copy_reference_to_clipboard"),
command: "copyImageReferenceToClipboard", command: "copyImageReferenceToClipboard",
@@ -38,48 +30,7 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
} }
], ],
selectMenuItemHandler: async ({ command }) => { selectMenuItemHandler: async ({ command }) => {
if (command === "viewInLightbox") { if (command === "copyImageReferenceToClipboard") {
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); imageService.copyImageReferenceToClipboard($image);
} else if (command === "copyImageToClipboard") { } else if (command === "copyImageToClipboard") {
try { try {

View File

@@ -3,7 +3,6 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
import glob from "./services/glob.js"; import glob from "./services/glob.js";
import "./stylesheets/bootstrap.scss"; import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css"; import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "autocomplete.js/index_jquery.js"; import "autocomplete.js/index_jquery.js";
glob.setupGlobs(); glob.setupGlobs();

View File

@@ -1,521 +0,0 @@
/**
* CKEditor PhotoSwipe Integration
* Handles click-to-lightbox functionality for images in CKEditor content
*/
import mediaViewer from './media_viewer.js';
import galleryManager from './gallery_manager.js';
import appContext from '../components/app_context.js';
import type { MediaItem } from './media_viewer.js';
import type { GalleryItem } from './gallery_manager.js';
/**
* Configuration for CKEditor PhotoSwipe integration
*/
interface CKEditorPhotoSwipeConfig {
enableGalleryMode?: boolean;
showHints?: boolean;
hintDelay?: number;
excludeSelector?: string;
}
/**
* Integration manager for CKEditor and PhotoSwipe
*/
class CKEditorPhotoSwipeIntegration {
private static instance: CKEditorPhotoSwipeIntegration;
private config: Required<CKEditorPhotoSwipeConfig>;
private observers: Map<HTMLElement, MutationObserver> = new Map();
private processedImages: WeakSet<HTMLImageElement> = new WeakSet();
private containerGalleries: Map<HTMLElement, GalleryItem[]> = new Map();
private hintPool: HTMLElement[] = [];
private activeHints: Map<string, HTMLElement> = new Map();
private hintTimeouts: Map<string, number> = new Map();
private constructor() {
this.config = {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.no-lightbox, .cke_widget_element'
};
}
/**
* Get singleton instance
*/
static getInstance(): CKEditorPhotoSwipeIntegration {
if (!CKEditorPhotoSwipeIntegration.instance) {
CKEditorPhotoSwipeIntegration.instance = new CKEditorPhotoSwipeIntegration();
}
return CKEditorPhotoSwipeIntegration.instance;
}
/**
* Setup integration for a CKEditor content container
*/
setupContainer(container: HTMLElement | JQuery<HTMLElement>, config?: Partial<CKEditorPhotoSwipeConfig>): void {
const element = container instanceof $ ? container[0] : container;
if (!element) return;
// Merge configuration
if (config) {
this.config = { ...this.config, ...config };
}
// Process existing images
this.processImages(element);
// Setup mutation observer for dynamically added images
this.observeContainer(element);
// Setup gallery if enabled
if (this.config.enableGalleryMode) {
this.setupGalleryMode(element);
}
}
/**
* Process all images in a container
*/
private processImages(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
images.forEach(img => {
if (!this.processedImages.has(img)) {
this.setupImageLightbox(img);
this.processedImages.add(img);
}
});
}
/**
* Setup lightbox for a single image
*/
private setupImageLightbox(img: HTMLImageElement): void {
// Skip if already processed or is a CKEditor widget element
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
return;
}
// Make image clickable and mark it as PhotoSwipe-enabled
img.style.cursor = 'zoom-in';
img.style.transition = 'opacity 0.2s';
img.classList.add('photoswipe-enabled');
img.setAttribute('data-photoswipe', 'true');
// Store event handlers for cleanup
const mouseEnterHandler = () => {
img.style.opacity = '0.9';
if (this.config.showHints) {
this.showHint(img);
}
};
const mouseLeaveHandler = () => {
img.style.opacity = '1';
this.hideHint(img);
};
// Add hover effect with cleanup tracking
img.addEventListener('mouseenter', mouseEnterHandler);
img.addEventListener('mouseleave', mouseLeaveHandler);
// Store handlers for cleanup
(img as any)._photoswipeHandlers = { mouseEnterHandler, mouseLeaveHandler };
// Add double-click handler to prevent default navigation behavior
const dblClickHandler = (e: MouseEvent) => {
// Only prevent double-click in specific contexts to avoid breaking other features
if (img.closest('.attachment-detail-wrapper') ||
img.closest('.note-detail-editable-text') ||
img.closest('.note-detail-readonly-text')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Trigger the same behavior as single click (open lightbox)
img.click();
}
};
img.addEventListener('dblclick', dblClickHandler, true); // Use capture phase to ensure we get it first
(img as any)._photoswipeHandlers.dblClickHandler = dblClickHandler;
// Add click handler
img.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Check if we should open as gallery
const container = img.closest('.note-detail-editable-text, .note-detail-readonly-text');
if (container && this.config.enableGalleryMode) {
const gallery = this.containerGalleries.get(container as HTMLElement);
if (gallery && gallery.length > 1) {
// Find index of clicked image
const index = gallery.findIndex(item => {
const itemElement = document.querySelector(`img[src="${item.src}"]`);
return itemElement === img;
});
galleryManager.openGallery(gallery, index >= 0 ? index : 0, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true,
loop: true
});
return;
}
}
// Open single image
this.openSingleImage(img);
});
// Add keyboard support
img.setAttribute('tabindex', '0');
img.setAttribute('role', 'button');
img.setAttribute('aria-label', 'Click to view in lightbox');
img.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
img.click();
}
});
}
/**
* Open a single image in lightbox
*/
private openSingleImage(img: HTMLImageElement): void {
const item: MediaItem = {
src: img.src,
alt: img.alt || 'Image',
title: img.title || img.alt,
element: img,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
const rect = img.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
}, {
onClose: () => {
// Check if we're in attachment detail view and need to reset viewScope
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext?.viewScope?.viewMode === 'attachments') {
// Get the note ID from the image source
const attachmentMatch = img.src.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//);
if (attachmentMatch) {
const currentAttachmentId = activeContext.viewScope.attachmentId;
if (currentAttachmentId === attachmentMatch[1]) {
// Actually reset the viewScope instead of just logging
try {
if (activeContext.note) {
activeContext.setNote(activeContext.note.noteId, {
viewScope: { viewMode: 'default' }
});
}
} catch (error) {
console.error('Failed to reset viewScope after PhotoSwipe close:', error);
}
}
}
}
// Restore focus to the image
img.focus();
}
});
}
/**
* Setup gallery mode for a container
*/
private setupGalleryMode(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
if (images.length <= 1) return;
const galleryItems: GalleryItem[] = [];
images.forEach((img, index) => {
// Skip CKEditor widget elements
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
return;
}
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${index + 1}`,
title: img.title || img.alt,
element: img,
index: index,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Check for caption
const figure = img.closest('figure');
if (figure) {
const caption = figure.querySelector('figcaption');
if (caption) {
item.caption = caption.textContent || undefined;
}
}
galleryItems.push(item);
});
if (galleryItems.length > 0) {
this.containerGalleries.set(container, galleryItems);
}
}
/**
* Observe container for dynamic changes
*/
private observeContainer(container: HTMLElement): void {
// Disconnect existing observer if any
const existingObserver = this.observers.get(container);
if (existingObserver) {
existingObserver.disconnect();
}
const observer = new MutationObserver((mutations) => {
let hasNewImages = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (element.tagName === 'IMG') {
hasNewImages = true;
} else if (element.querySelector('img')) {
hasNewImages = true;
}
}
});
}
});
if (hasNewImages) {
// Process new images
this.processImages(container);
// Update gallery if enabled
if (this.config.enableGalleryMode) {
this.setupGalleryMode(container);
}
}
});
observer.observe(container, {
childList: true,
subtree: true
});
this.observers.set(container, observer);
}
/**
* Get or create a hint element from the pool
*/
private getHintFromPool(): HTMLElement {
let hint = this.hintPool.pop();
if (!hint) {
hint = document.createElement('div');
hint.className = 'ckeditor-image-hint';
hint.textContent = 'Click to view in lightbox';
hint.style.cssText = `
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
display: none;
`;
}
return hint;
}
/**
* Return hint to pool
*/
private returnHintToPool(hint: HTMLElement): void {
hint.style.opacity = '0';
hint.style.display = 'none';
if (this.hintPool.length < 10) { // Keep max 10 hints in pool
this.hintPool.push(hint);
} else {
hint.remove();
}
}
/**
* Show hint for an image
*/
private showHint(img: HTMLImageElement): void {
// Check if hint already exists
const imgId = img.dataset.imgId || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
if (!img.dataset.imgId) {
img.dataset.imgId = imgId;
}
// Clear any existing timeout
const existingTimeout = this.hintTimeouts.get(imgId);
if (existingTimeout) {
clearTimeout(existingTimeout);
this.hintTimeouts.delete(imgId);
}
let hint = this.activeHints.get(imgId);
if (hint) {
hint.style.opacity = '1';
return;
}
// Get hint from pool
hint = this.getHintFromPool();
this.activeHints.set(imgId, hint);
// Position and show hint
if (!hint.parentElement) {
document.body.appendChild(hint);
}
const imgRect = img.getBoundingClientRect();
hint.style.display = 'block';
hint.style.left = `${imgRect.left + (imgRect.width - hint.offsetWidth) / 2}px`;
hint.style.top = `${imgRect.top - hint.offsetHeight - 5}px`;
// Show hint
requestAnimationFrame(() => {
hint.style.opacity = '1';
});
// Auto-hide after delay
const timeout = window.setTimeout(() => {
this.hideHint(img);
}, this.config.hintDelay);
this.hintTimeouts.set(imgId, timeout);
}
/**
* Hide hint for an image
*/
private hideHint(img: HTMLImageElement): void {
const imgId = img.dataset.imgId;
if (!imgId) return;
// Clear timeout
const timeout = this.hintTimeouts.get(imgId);
if (timeout) {
clearTimeout(timeout);
this.hintTimeouts.delete(imgId);
}
const hint = this.activeHints.get(imgId);
if (hint) {
hint.style.opacity = '0';
this.activeHints.delete(imgId);
setTimeout(() => {
this.returnHintToPool(hint);
}, 300);
}
}
/**
* Cleanup integration for a container
*/
cleanupContainer(container: HTMLElement | JQuery<HTMLElement>): void {
const element = container instanceof $ ? container[0] : container;
if (!element) return;
// Disconnect observer
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
}
// Clear gallery
this.containerGalleries.delete(element);
// Remove event handlers and hints
const images = element.querySelectorAll<HTMLImageElement>('img');
images.forEach(img => {
this.hideHint(img);
// Remove event handlers
const handlers = (img as any)._photoswipeHandlers;
if (handlers) {
img.removeEventListener('mouseenter', handlers.mouseEnterHandler);
img.removeEventListener('mouseleave', handlers.mouseLeaveHandler);
if (handlers.dblClickHandler) {
img.removeEventListener('dblclick', handlers.dblClickHandler, true);
}
delete (img as any)._photoswipeHandlers;
}
// Mark as unprocessed
this.processedImages.delete(img);
});
}
/**
* Update configuration
*/
updateConfig(config: Partial<CKEditorPhotoSwipeConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Cleanup all integrations
*/
cleanup(): void {
// Disconnect all observers
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
// Clear all galleries
this.containerGalleries.clear();
// Clear all hints
this.activeHints.forEach(hint => hint.remove());
this.activeHints.clear();
// Clear all timeouts
this.hintTimeouts.forEach(timeout => clearTimeout(timeout));
this.hintTimeouts.clear();
// Clear hint pool
this.hintPool.forEach(hint => hint.remove());
this.hintPool = [];
// Clear processed images
this.processedImages = new WeakSet();
}
}
// Export singleton instance
export default CKEditorPhotoSwipeIntegration.getInstance();

View File

@@ -1,387 +0,0 @@
/**
* Tests for Gallery Manager
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import galleryManager from './gallery_manager';
import mediaViewer from './media_viewer';
import type { GalleryItem, GalleryConfig } from './gallery_manager';
import type { MediaViewerCallbacks } from './media_viewer';
// Mock media viewer
vi.mock('./media_viewer', () => ({
default: {
open: vi.fn(),
openSingle: vi.fn(),
close: vi.fn(),
next: vi.fn(),
prev: vi.fn(),
goTo: vi.fn(),
getCurrentIndex: vi.fn(() => 0),
isOpen: vi.fn(() => false),
getImageDimensions: vi.fn(() => Promise.resolve({ width: 800, height: 600 }))
}
}));
// Mock froca
vi.mock('./froca', () => ({
default: {
getNoteComplement: vi.fn()
}
}));
// Mock utils
vi.mock('./utils', () => ({
default: {
createImageSrcUrl: vi.fn((note: any) => `/api/images/${note.noteId}`),
randomString: vi.fn(() => 'test123')
}
}));
describe('GalleryManager', () => {
let mockItems: GalleryItem[];
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Create mock gallery items
mockItems = [
{
src: '/api/images/note1/image1.jpg',
alt: 'Image 1',
title: 'First Image',
noteId: 'note1',
index: 0,
width: 800,
height: 600
},
{
src: '/api/images/note1/image2.jpg',
alt: 'Image 2',
title: 'Second Image',
noteId: 'note1',
index: 1,
width: 1024,
height: 768
},
{
src: '/api/images/note1/image3.jpg',
alt: 'Image 3',
title: 'Third Image',
noteId: 'note1',
index: 2,
width: 1920,
height: 1080
}
];
// Setup DOM
document.body.innerHTML = '';
});
afterEach(() => {
// Cleanup
galleryManager.cleanup();
document.body.innerHTML = '';
});
describe('Gallery Creation', () => {
it('should create gallery from container with images', async () => {
// Create container with images
const container = document.createElement('div');
container.innerHTML = `
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
<img src="/api/images/note1/image2.jpg" alt="Image 2" />
<img src="/api/images/note1/image3.jpg" alt="Image 3" />
`;
document.body.appendChild(container);
// Create gallery from container
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(3);
expect(items[0].src).toBe('/api/images/note1/image1.jpg');
expect(items[0].alt).toBe('Image 1');
expect(items[0].index).toBe(0);
});
it('should extract captions from figure elements', async () => {
const container = document.createElement('div');
container.innerHTML = `
<figure>
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
<figcaption>This is a caption</figcaption>
</figure>
`;
document.body.appendChild(container);
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(1);
expect(items[0].caption).toBe('This is a caption');
});
it('should handle images without dimensions', async () => {
const container = document.createElement('div');
container.innerHTML = `<img src="/api/images/note1/image1.jpg" alt="Image 1" />`;
document.body.appendChild(container);
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(1);
expect(items[0].width).toBe(800); // From mocked getImageDimensions
expect(items[0].height).toBe(600);
expect(mediaViewer.getImageDimensions).toHaveBeenCalledWith('/api/images/note1/image1.jpg');
});
});
describe('Gallery Opening', () => {
it('should open gallery with multiple items', () => {
const callbacks: MediaViewerCallbacks = {
onOpen: vi.fn(),
onClose: vi.fn(),
onChange: vi.fn()
};
galleryManager.openGallery(mockItems, 0, {}, callbacks);
expect(mediaViewer.open).toHaveBeenCalledWith(
mockItems,
0,
expect.objectContaining({
loop: true,
allowPanToNext: true,
preload: [2, 2]
}),
expect.objectContaining({
onOpen: expect.any(Function),
onClose: expect.any(Function),
onChange: expect.any(Function)
})
);
});
it('should handle empty items array', () => {
galleryManager.openGallery([], 0);
expect(mediaViewer.open).not.toHaveBeenCalled();
});
it('should apply custom configuration', () => {
const config: GalleryConfig = {
showThumbnails: false,
autoPlay: true,
slideInterval: 5000,
loop: false
};
galleryManager.openGallery(mockItems, 0, config);
expect(mediaViewer.open).toHaveBeenCalledWith(
mockItems,
0,
expect.objectContaining({
loop: false
}),
expect.any(Object)
);
});
});
describe('Gallery Navigation', () => {
beforeEach(() => {
// Open a gallery first
galleryManager.openGallery(mockItems, 0);
});
it('should navigate to next slide', () => {
galleryManager.nextSlide();
expect(mediaViewer.next).toHaveBeenCalled();
});
it('should navigate to previous slide', () => {
galleryManager.previousSlide();
expect(mediaViewer.prev).toHaveBeenCalled();
});
it('should go to specific slide', () => {
galleryManager.goToSlide(2);
expect(mediaViewer.goTo).toHaveBeenCalledWith(2);
});
it('should not navigate to invalid slide index', () => {
const state = galleryManager.getGalleryState();
if (state) {
// Try to go to invalid index
galleryManager.goToSlide(-1);
expect(mediaViewer.goTo).not.toHaveBeenCalled();
galleryManager.goToSlide(10);
expect(mediaViewer.goTo).not.toHaveBeenCalled();
}
});
});
describe('Slideshow Functionality', () => {
beforeEach(() => {
vi.useFakeTimers();
galleryManager.openGallery(mockItems, 0, { autoPlay: false });
});
afterEach(() => {
vi.useRealTimers();
});
it('should start slideshow', () => {
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(false);
galleryManager.startSlideshow();
const updatedState = galleryManager.getGalleryState();
expect(updatedState?.isPlaying).toBe(true);
});
it('should stop slideshow', () => {
galleryManager.startSlideshow();
galleryManager.stopSlideshow();
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(false);
});
it('should toggle slideshow', () => {
const initialState = galleryManager.getGalleryState();
expect(initialState?.isPlaying).toBe(false);
galleryManager.toggleSlideshow();
expect(galleryManager.getGalleryState()?.isPlaying).toBe(true);
galleryManager.toggleSlideshow();
expect(galleryManager.getGalleryState()?.isPlaying).toBe(false);
});
it('should advance slides automatically in slideshow', () => {
galleryManager.startSlideshow();
// Fast-forward time
vi.advanceTimersByTime(4000); // Default interval
expect(mediaViewer.goTo).toHaveBeenCalledWith(1);
});
it('should update slideshow interval', () => {
galleryManager.startSlideshow();
galleryManager.updateSlideshowInterval(5000);
const state = galleryManager.getGalleryState();
expect(state?.config.slideInterval).toBe(5000);
});
});
describe('Gallery State', () => {
it('should track gallery state', () => {
expect(galleryManager.getGalleryState()).toBeNull();
galleryManager.openGallery(mockItems, 1);
const state = galleryManager.getGalleryState();
expect(state).not.toBeNull();
expect(state?.items).toEqual(mockItems);
expect(state?.currentIndex).toBe(1);
});
it('should check if gallery is open', () => {
expect(galleryManager.isGalleryOpen()).toBe(false);
vi.mocked(mediaViewer.isOpen).mockReturnValue(true);
galleryManager.openGallery(mockItems, 0);
expect(galleryManager.isGalleryOpen()).toBe(true);
});
});
describe('Gallery Cleanup', () => {
it('should close gallery on cleanup', () => {
galleryManager.openGallery(mockItems, 0);
galleryManager.cleanup();
expect(mediaViewer.close).toHaveBeenCalled();
expect(galleryManager.getGalleryState()).toBeNull();
});
it('should stop slideshow on close', () => {
galleryManager.openGallery(mockItems, 0, { autoPlay: true });
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(true);
galleryManager.closeGallery();
expect(mediaViewer.close).toHaveBeenCalled();
});
});
describe('UI Enhancements', () => {
beforeEach(() => {
// Create PhotoSwipe container mock
const pswpElement = document.createElement('div');
pswpElement.className = 'pswp';
document.body.appendChild(pswpElement);
});
it('should add thumbnail strip when enabled', (done) => {
galleryManager.openGallery(mockItems, 0, { showThumbnails: true });
// Wait for UI setup
setTimeout(() => {
const thumbnailStrip = document.querySelector('.gallery-thumbnail-strip');
expect(thumbnailStrip).toBeTruthy();
const thumbnails = document.querySelectorAll('.gallery-thumbnail');
expect(thumbnails).toHaveLength(3);
done();
}, 150);
});
it('should add slideshow controls', (done) => {
galleryManager.openGallery(mockItems, 0);
setTimeout(() => {
const controls = document.querySelector('.gallery-slideshow-controls');
expect(controls).toBeTruthy();
const playPauseBtn = document.querySelector('.slideshow-play-pause');
expect(playPauseBtn).toBeTruthy();
done();
}, 150);
});
it('should add image counter when enabled', (done) => {
galleryManager.openGallery(mockItems, 0, { showCounter: true });
setTimeout(() => {
const counter = document.querySelector('.gallery-counter');
expect(counter).toBeTruthy();
expect(counter?.textContent).toContain('1');
expect(counter?.textContent).toContain('3');
done();
}, 150);
});
it('should add keyboard hints', (done) => {
galleryManager.openGallery(mockItems, 0);
setTimeout(() => {
const hints = document.querySelector('.gallery-keyboard-hints');
expect(hints).toBeTruthy();
expect(hints?.textContent).toContain('Navigate');
expect(hints?.textContent).toContain('ESC');
done();
}, 150);
});
});
});

View File

@@ -1,987 +0,0 @@
/**
* Gallery Manager for PhotoSwipe integration in Trilium Notes
* Handles multi-image galleries, slideshow mode, and navigation features
*/
import mediaViewer, { MediaItem, MediaViewerCallbacks, MediaViewerConfig } from './media_viewer.js';
import utils from './utils.js';
import froca from './froca.js';
import type FNote from '../entities/fnote.js';
/**
* Gallery configuration options
*/
export interface GalleryConfig {
showThumbnails?: boolean;
thumbnailHeight?: number;
autoPlay?: boolean;
slideInterval?: number; // in milliseconds
showCounter?: boolean;
enableKeyboardNav?: boolean;
enableSwipeGestures?: boolean;
preloadCount?: number;
loop?: boolean;
}
/**
* Gallery item with additional metadata
*/
export interface GalleryItem extends MediaItem {
noteId?: string;
attachmentId?: string;
caption?: string;
description?: string;
index?: number;
}
/**
* Gallery state management
*/
interface GalleryState {
items: GalleryItem[];
currentIndex: number;
isPlaying: boolean;
slideshowTimer?: number;
config: Required<GalleryConfig>;
}
/**
* GalleryManager handles multi-image galleries with slideshow and navigation features
*/
class GalleryManager {
private static instance: GalleryManager;
private currentGallery: GalleryState | null = null;
private defaultConfig: Required<GalleryConfig> = {
showThumbnails: true,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: true,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
private slideshowCallbacks: Set<() => void> = new Set();
private $thumbnailStrip?: JQuery<HTMLElement>;
private $slideshowControls?: JQuery<HTMLElement>;
// Track all dynamically created elements for proper cleanup
private createdElements: Map<string, HTMLElement | JQuery<HTMLElement>> = new Map();
private setupTimeout?: number;
private constructor() {
// Cleanup on window unload
window.addEventListener('beforeunload', () => this.cleanup());
}
/**
* Get singleton instance
*/
static getInstance(): GalleryManager {
if (!GalleryManager.instance) {
GalleryManager.instance = new GalleryManager();
}
return GalleryManager.instance;
}
/**
* Create gallery from images in a note's content
*/
async createGalleryFromNote(note: FNote, config?: GalleryConfig): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
try {
// Parse note content to find images
const parser = new DOMParser();
const content = await note.getContent();
const doc = parser.parseFromString(content || '', 'text/html');
const images = doc.querySelectorAll('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute('src');
if (!src) continue;
// Convert relative URLs to absolute
const absoluteSrc = this.resolveImageSrc(src, note.noteId);
const item: GalleryItem = {
src: absoluteSrc,
alt: img.getAttribute('alt') || `Image ${i + 1} from ${note.title}`,
title: img.getAttribute('title') || img.getAttribute('alt') || undefined,
caption: img.getAttribute('data-caption') || undefined,
noteId: note.noteId,
index: i,
width: parseInt(img.getAttribute('width') || '0') || undefined,
height: parseInt(img.getAttribute('height') || '0') || undefined
};
// Try to get thumbnail from data attribute or create one
const thumbnailSrc = img.getAttribute('data-thumbnail');
if (thumbnailSrc) {
item.msrc = this.resolveImageSrc(thumbnailSrc, note.noteId);
}
items.push(item);
}
// Also check for image attachments
const attachmentItems = await this.getAttachmentImages(note);
items.push(...attachmentItems);
} catch (error) {
console.error('Failed to create gallery from note:', error);
}
return items;
}
/**
* Get image attachments from a note
*/
private async getAttachmentImages(note: FNote): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
try {
// Get child notes that are images
const childNotes = await note.getChildNotes();
for (const childNote of childNotes) {
if (childNote.type === 'image') {
const item: GalleryItem = {
src: utils.createImageSrcUrl(childNote),
alt: childNote.title,
title: childNote.title,
noteId: childNote.noteId,
index: items.length
};
items.push(item);
}
}
} catch (error) {
console.error('Failed to get attachment images:', error);
}
return items;
}
/**
* Create gallery from a container element with images
*/
async createGalleryFromContainer(
container: HTMLElement | JQuery<HTMLElement>,
selector: string = 'img',
config?: GalleryConfig
): Promise<GalleryItem[]> {
const $container = $(container);
const images = $container.find(selector);
const items: GalleryItem[] = [];
for (let i = 0; i < images.length; i++) {
const img = images[i] as HTMLImageElement;
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${i + 1}`,
title: img.title || img.alt || undefined,
element: img,
index: i,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Try to extract caption from nearby elements
const $img = $(img);
const $figure = $img.closest('figure');
if ($figure.length) {
const $caption = $figure.find('figcaption');
if ($caption.length) {
item.caption = $caption.text();
}
}
// Check for data attributes
item.noteId = $img.data('note-id');
item.attachmentId = $img.data('attachment-id');
items.push(item);
}
return items;
}
/**
* Open gallery with specified items
*/
openGallery(
items: GalleryItem[],
startIndex: number = 0,
config?: GalleryConfig,
callbacks?: MediaViewerCallbacks
): void {
if (!items || items.length === 0) {
console.warn('No items provided to gallery');
return;
}
// Close any existing gallery
this.closeGallery();
// Merge configuration
const finalConfig = { ...this.defaultConfig, ...config };
// Initialize gallery state
this.currentGallery = {
items,
currentIndex: startIndex,
isPlaying: finalConfig.autoPlay,
config: finalConfig
};
// Enhanced PhotoSwipe configuration for gallery
const photoSwipeConfig: Partial<MediaViewerConfig> = {
bgOpacity: 0.95,
showHideOpacity: true,
allowPanToNext: true,
spacing: 0.12,
loop: finalConfig.loop,
arrowKeys: finalConfig.enableKeyboardNav,
pinchToClose: finalConfig.enableSwipeGestures,
closeOnVerticalDrag: finalConfig.enableSwipeGestures,
preload: [finalConfig.preloadCount, finalConfig.preloadCount],
wheelToZoom: true,
// Enable mobile and accessibility enhancements
mobileA11y: {
touch: {
hapticFeedback: true,
multiTouchEnabled: true
},
a11y: {
enableKeyboardNav: finalConfig.enableKeyboardNav,
enableScreenReaderAnnouncements: true,
keyboardShortcutsEnabled: true
},
mobileUI: {
bottomSheetEnabled: true,
adaptiveToolbar: true,
swipeIndicators: true,
gestureHints: true
},
performance: {
adaptiveQuality: true,
batteryOptimization: true
}
}
};
// Enhanced callbacks
const enhancedCallbacks: MediaViewerCallbacks = {
onOpen: () => {
this.onGalleryOpen();
callbacks?.onOpen?.();
},
onClose: () => {
this.onGalleryClose();
callbacks?.onClose?.();
},
onChange: (index) => {
this.onSlideChange(index);
callbacks?.onChange?.(index);
},
onImageLoad: callbacks?.onImageLoad,
onImageError: callbacks?.onImageError
};
// Open with media viewer
mediaViewer.open(items, startIndex, photoSwipeConfig, enhancedCallbacks);
// Setup gallery UI enhancements
this.setupGalleryUI();
// Start slideshow if configured
if (finalConfig.autoPlay) {
this.startSlideshow();
}
}
/**
* Setup gallery UI enhancements
*/
private setupGalleryUI(): void {
if (!this.currentGallery) return;
// Clear any existing timeout
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
}
// Add gallery-specific UI elements to PhotoSwipe
this.setupTimeout = window.setTimeout(() => {
// Validate gallery is still open before manipulating DOM
if (!this.currentGallery || !this.isGalleryOpen()) {
return;
}
// PhotoSwipe needs a moment to initialize
const pswpElement = document.querySelector('.pswp');
if (!pswpElement) return;
// Add thumbnail strip if enabled
if (this.currentGallery.config.showThumbnails) {
this.addThumbnailStrip(pswpElement);
}
// Add slideshow controls
this.addSlideshowControls(pswpElement);
// Add image counter if enabled
if (this.currentGallery.config.showCounter) {
this.addImageCounter(pswpElement);
}
// Add keyboard hints
this.addKeyboardHints(pswpElement);
}, 100);
}
/**
* Add thumbnail strip navigation
*/
private addThumbnailStrip(container: Element): void {
if (!this.currentGallery) return;
// Create thumbnail strip container safely using DOM APIs
const stripDiv = document.createElement('div');
stripDiv.className = 'gallery-thumbnail-strip';
stripDiv.setAttribute('style', `
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 10px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
max-width: 90%;
overflow-x: auto;
z-index: 100;
`);
// Create thumbnails safely
this.currentGallery.items.forEach((item, index) => {
const thumbDiv = document.createElement('div');
thumbDiv.className = 'gallery-thumbnail';
thumbDiv.dataset.index = index.toString();
thumbDiv.setAttribute('style', `
width: ${this.currentGallery!.config.thumbnailHeight}px;
height: ${this.currentGallery!.config.thumbnailHeight}px;
cursor: pointer;
border: 2px solid ${index === this.currentGallery!.currentIndex ? '#fff' : 'transparent'};
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
opacity: ${index === this.currentGallery!.currentIndex ? '1' : '0.6'};
transition: all 0.2s;
`);
const img = document.createElement('img');
// Sanitize src URLs
const src = this.sanitizeUrl(item.msrc || item.src);
img.src = src;
// Use textContent for safe text insertion
img.alt = this.sanitizeText(item.alt || '');
img.setAttribute('style', `
width: 100%;
height: 100%;
object-fit: cover;
`);
thumbDiv.appendChild(img);
stripDiv.appendChild(thumbDiv);
});
this.$thumbnailStrip = $(stripDiv);
$(container).append(this.$thumbnailStrip);
this.createdElements.set('thumbnailStrip', this.$thumbnailStrip);
// Handle thumbnail clicks
this.$thumbnailStrip.on('click', '.gallery-thumbnail', (e) => {
const index = parseInt($(e.currentTarget).data('index'));
this.goToSlide(index);
});
// Handle hover effect
this.$thumbnailStrip.on('mouseenter', '.gallery-thumbnail', (e) => {
if (!$(e.currentTarget).hasClass('active')) {
$(e.currentTarget).css('opacity', '0.8');
}
});
this.$thumbnailStrip.on('mouseleave', '.gallery-thumbnail', (e) => {
if (!$(e.currentTarget).hasClass('active')) {
$(e.currentTarget).css('opacity', '0.6');
}
});
}
/**
* Sanitize text content to prevent XSS
*/
private sanitizeText(text: string): string {
// Remove any HTML tags and entities
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Sanitize URL to prevent XSS
*/
private sanitizeUrl(url: string): string {
// Only allow safe protocols
const allowedProtocols = ['http:', 'https:', 'data:'];
try {
const urlObj = new URL(url, window.location.href);
// Special validation for data URLs
if (urlObj.protocol === 'data:') {
// Only allow image MIME types for data URLs
const allowedImageTypes = [
'data:image/jpeg',
'data:image/jpg',
'data:image/png',
'data:image/gif',
'data:image/webp',
'data:image/svg+xml',
'data:image/bmp'
];
// Check if data URL starts with an allowed image type
const isAllowedImage = allowedImageTypes.some(type =>
url.toLowerCase().startsWith(type)
);
if (!isAllowedImage) {
console.warn('Rejected non-image data URL:', url.substring(0, 50));
return '';
}
// Additional check for base64 encoding
if (!url.includes(';base64,') && !url.includes(';charset=')) {
console.warn('Rejected data URL with invalid encoding');
return '';
}
} else if (!allowedProtocols.includes(urlObj.protocol)) {
return '';
}
return urlObj.href;
} catch {
// If URL parsing fails, check if it's a relative path
if (url.startsWith('/') || url.startsWith('api/')) {
return url;
}
return '';
}
}
/**
* Add slideshow controls
*/
private addSlideshowControls(container: Element): void {
if (!this.currentGallery) return;
const controlsHtml = `
<div class="gallery-slideshow-controls" style="
position: absolute;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 100;
">
<button class="slideshow-play-pause" style="
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
width: 44px;
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
" aria-label="${this.currentGallery.isPlaying ? 'Pause slideshow' : 'Play slideshow'}">
<i class="bx ${this.currentGallery.isPlaying ? 'bx-pause' : 'bx-play'}"></i>
</button>
<button class="slideshow-settings" style="
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
width: 44px;
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
" aria-label="Slideshow settings">
<i class="bx bx-cog"></i>
</button>
</div>
<div class="slideshow-interval-selector" style="
position: absolute;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 4px;
display: none;
z-index: 101;
">
<label style="display: block; margin-bottom: 5px;">Slide interval:</label>
<select class="interval-select" style="
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 4px;
border-radius: 3px;
">
<option value="3000">3 seconds</option>
<option value="4000" selected>4 seconds</option>
<option value="5000">5 seconds</option>
<option value="7000">7 seconds</option>
<option value="10000">10 seconds</option>
</select>
</div>
`;
this.$slideshowControls = $(controlsHtml);
$(container).append(this.$slideshowControls);
this.createdElements.set('slideshowControls', this.$slideshowControls);
// Handle play/pause button
this.$slideshowControls.find('.slideshow-play-pause').on('click', () => {
this.toggleSlideshow();
});
// Handle settings button
this.$slideshowControls.find('.slideshow-settings').on('click', () => {
const $selector = this.$slideshowControls?.find('.slideshow-interval-selector');
$selector?.toggle();
});
// Handle interval change
this.$slideshowControls.find('.interval-select').on('change', (e) => {
const interval = parseInt($(e.target).val() as string);
this.updateSlideshowInterval(interval);
});
}
/**
* Add image counter
*/
private addImageCounter(container: Element): void {
if (!this.currentGallery) return;
// Create counter element safely
const counterDiv = document.createElement('div');
counterDiv.className = 'gallery-counter';
counterDiv.setAttribute('style', `
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 100;
`);
const currentSpan = document.createElement('span');
currentSpan.className = 'current-index';
currentSpan.textContent = String(this.currentGallery.currentIndex + 1);
const separatorSpan = document.createElement('span');
separatorSpan.textContent = ' / ';
const totalSpan = document.createElement('span');
totalSpan.className = 'total-count';
totalSpan.textContent = String(this.currentGallery.items.length);
counterDiv.appendChild(currentSpan);
counterDiv.appendChild(separatorSpan);
counterDiv.appendChild(totalSpan);
container.appendChild(counterDiv);
this.createdElements.set('counter', counterDiv);
}
/**
* Add keyboard hints overlay
*/
private addKeyboardHints(container: Element): void {
// Create hints element safely
const hintsDiv = document.createElement('div');
hintsDiv.className = 'gallery-keyboard-hints';
hintsDiv.setAttribute('style', `
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
z-index: 100;
`);
// Create hint items
const hints = [
{ key: '←/→', action: 'Navigate' },
{ key: 'Space', action: 'Play/Pause' },
{ key: 'ESC', action: 'Close' }
];
hints.forEach(hint => {
const hintItem = document.createElement('div');
const kbd = document.createElement('kbd');
kbd.style.cssText = 'background: rgba(255,255,255,0.2); padding: 2px 4px; border-radius: 2px;';
kbd.textContent = hint.key;
hintItem.appendChild(kbd);
hintItem.appendChild(document.createTextNode(' ' + hint.action));
hintsDiv.appendChild(hintItem);
});
container.appendChild(hintsDiv);
this.createdElements.set('keyboardHints', hintsDiv);
const $hints = $(hintsDiv);
// Show hints on hover with scoped selector
const handleMouseEnter = () => {
if (this.currentGallery) {
$hints.css('opacity', '0.6');
}
};
const handleMouseLeave = () => {
$hints.css('opacity', '0');
};
$(container).on('mouseenter.galleryHints', handleMouseEnter);
$(container).on('mouseleave.galleryHints', handleMouseLeave);
// Track cleanup callback
this.slideshowCallbacks.add(() => {
$(container).off('.galleryHints');
});
}
/**
* Handle gallery open event
*/
private onGalleryOpen(): void {
// Add keyboard listener for slideshow control
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === ' ') {
e.preventDefault();
this.toggleSlideshow();
}
};
document.addEventListener('keydown', handleKeyDown);
this.slideshowCallbacks.add(() => {
document.removeEventListener('keydown', handleKeyDown);
});
}
/**
* Handle gallery close event
*/
private onGalleryClose(): void {
this.stopSlideshow();
// Clear setup timeout if exists
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
this.setupTimeout = undefined;
}
// Cleanup event listeners
this.slideshowCallbacks.forEach(callback => callback());
this.slideshowCallbacks.clear();
// Remove all tracked UI elements
this.createdElements.forEach((element, key) => {
if (element instanceof HTMLElement) {
element.remove();
} else if (element instanceof $) {
element.remove();
}
});
this.createdElements.clear();
// Clear jQuery references
this.$thumbnailStrip = undefined;
this.$slideshowControls = undefined;
// Clear state
this.currentGallery = null;
}
/**
* Handle slide change event
*/
private onSlideChange(index: number): void {
if (!this.currentGallery) return;
this.currentGallery.currentIndex = index;
// Update thumbnail highlighting
if (this.$thumbnailStrip) {
this.$thumbnailStrip.find('.gallery-thumbnail').each((i, el) => {
const $thumb = $(el);
if (i === index) {
$thumb.css({
'border-color': '#fff',
'opacity': '1'
});
// Scroll thumbnail into view
const thumbLeft = $thumb.position().left;
const thumbWidth = $thumb.outerWidth() || 0;
const stripWidth = this.$thumbnailStrip!.width() || 0;
const scrollLeft = this.$thumbnailStrip!.scrollLeft() || 0;
if (thumbLeft < 0) {
this.$thumbnailStrip!.scrollLeft(scrollLeft + thumbLeft - 10);
} else if (thumbLeft + thumbWidth > stripWidth) {
this.$thumbnailStrip!.scrollLeft(scrollLeft + (thumbLeft + thumbWidth - stripWidth) + 10);
}
} else {
$thumb.css({
'border-color': 'transparent',
'opacity': '0.6'
});
}
});
}
// Update counter using tracked element
const counterElement = this.createdElements.get('counter');
if (counterElement instanceof HTMLElement) {
const currentIndexElement = counterElement.querySelector('.current-index');
if (currentIndexElement) {
currentIndexElement.textContent = String(index + 1);
}
}
}
/**
* Start slideshow
*/
startSlideshow(): void {
if (!this.currentGallery || this.currentGallery.isPlaying) return;
// Validate gallery state before starting slideshow
if (!this.isGalleryOpen() || this.currentGallery.items.length === 0) {
console.warn('Cannot start slideshow: gallery not ready');
return;
}
// Ensure PhotoSwipe is ready
if (!mediaViewer.isOpen()) {
console.warn('Cannot start slideshow: PhotoSwipe not ready');
return;
}
this.currentGallery.isPlaying = true;
// Update button icon
this.$slideshowControls?.find('.slideshow-play-pause i')
.removeClass('bx-play')
.addClass('bx-pause');
// Start timer
this.scheduleNextSlide();
}
/**
* Stop slideshow
*/
stopSlideshow(): void {
if (!this.currentGallery) return;
this.currentGallery.isPlaying = false;
// Clear timer
if (this.currentGallery.slideshowTimer) {
clearTimeout(this.currentGallery.slideshowTimer);
this.currentGallery.slideshowTimer = undefined;
}
// Update button icon
this.$slideshowControls?.find('.slideshow-play-pause i')
.removeClass('bx-pause')
.addClass('bx-play');
}
/**
* Toggle slideshow play/pause
*/
toggleSlideshow(): void {
if (!this.currentGallery) return;
if (this.currentGallery.isPlaying) {
this.stopSlideshow();
} else {
this.startSlideshow();
}
}
/**
* Schedule next slide in slideshow
*/
private scheduleNextSlide(): void {
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
// Clear any existing timer
if (this.currentGallery.slideshowTimer) {
clearTimeout(this.currentGallery.slideshowTimer);
}
this.currentGallery.slideshowTimer = window.setTimeout(() => {
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
// Go to next slide
const nextIndex = (this.currentGallery.currentIndex + 1) % this.currentGallery.items.length;
this.goToSlide(nextIndex);
// Schedule next transition
this.scheduleNextSlide();
}, this.currentGallery.config.slideInterval);
}
/**
* Update slideshow interval
*/
updateSlideshowInterval(interval: number): void {
if (!this.currentGallery) return;
this.currentGallery.config.slideInterval = interval;
// Restart slideshow with new interval if playing
if (this.currentGallery.isPlaying) {
this.stopSlideshow();
this.startSlideshow();
}
}
/**
* Go to specific slide
*/
goToSlide(index: number): void {
if (!this.currentGallery) return;
if (index >= 0 && index < this.currentGallery.items.length) {
mediaViewer.goTo(index);
}
}
/**
* Navigate to next slide
*/
nextSlide(): void {
mediaViewer.next();
}
/**
* Navigate to previous slide
*/
previousSlide(): void {
mediaViewer.prev();
}
/**
* Close gallery
*/
closeGallery(): void {
mediaViewer.close();
}
/**
* Check if gallery is open
*/
isGalleryOpen(): boolean {
return this.currentGallery !== null && mediaViewer.isOpen();
}
/**
* Get current gallery state
*/
getGalleryState(): GalleryState | null {
return this.currentGallery;
}
/**
* Resolve image source URL
*/
private resolveImageSrc(src: string, noteId: string): string {
// Handle different image source formats
if (src.startsWith('http://') || src.startsWith('https://')) {
return src;
}
if (src.startsWith('api/images/')) {
return `/${src}`;
}
if (src.startsWith('/')) {
return src;
}
// Assume it's a note ID or attachment reference
return `/api/images/${noteId}/${src}`;
}
/**
* Cleanup resources
*/
cleanup(): void {
// Clear any pending timeouts
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
this.setupTimeout = undefined;
}
this.closeGallery();
// Ensure all elements are removed
this.createdElements.forEach((element) => {
if (element instanceof HTMLElement) {
element.remove();
} else if (element instanceof $) {
element.remove();
}
});
this.createdElements.clear();
this.slideshowCallbacks.clear();
this.currentGallery = null;
}
}
// Export singleton instance
export default GalleryManager.getInstance();

View File

@@ -1,597 +0,0 @@
/**
* Image Annotations Module for PhotoSwipe
* Provides ability to add, display, and manage annotations on images
*/
import froca from './froca.js';
import server from './server.js';
import type FNote from '../entities/fnote.js';
import type FAttribute from '../entities/fattribute.js';
import { ImageValidator, withErrorBoundary, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Annotation position and data
*/
export interface ImageAnnotation {
id: string;
noteId: string;
x: number; // Percentage from left (0-100)
y: number; // Percentage from top (0-100)
text: string;
author?: string;
created: Date;
modified?: Date;
color?: string;
icon?: string;
type?: 'comment' | 'marker' | 'region';
width?: number; // For region type
height?: number; // For region type
}
/**
* Annotation configuration
*/
export interface AnnotationConfig {
enableAnnotations: boolean;
showByDefault: boolean;
allowEditing: boolean;
defaultColor: string;
defaultIcon: string;
}
/**
* ImageAnnotationsService manages image annotations using Trilium's attribute system
*/
class ImageAnnotationsService {
private static instance: ImageAnnotationsService;
private activeAnnotations: Map<string, ImageAnnotation[]> = new Map();
private annotationElements: Map<string, HTMLElement> = new Map();
private isEditMode: boolean = false;
private selectedAnnotation: ImageAnnotation | null = null;
private config: AnnotationConfig = {
enableAnnotations: true,
showByDefault: true,
allowEditing: true,
defaultColor: '#ffeb3b',
defaultIcon: 'bx-comment'
};
// Annotation attribute prefix in Trilium
private readonly ANNOTATION_PREFIX = 'imageAnnotation';
private constructor() {}
static getInstance(): ImageAnnotationsService {
if (!ImageAnnotationsService.instance) {
ImageAnnotationsService.instance = new ImageAnnotationsService();
}
return ImageAnnotationsService.instance;
}
/**
* Load annotations for an image note
*/
async loadAnnotations(noteId: string): Promise<ImageAnnotation[]> {
return await withErrorBoundary(async () => {
// Validate note ID
if (!noteId || typeof noteId !== 'string') {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid note ID provided'
);
}
const note = await froca.getNote(noteId);
if (!note) return [];
const attributes = note.getAttributes();
const annotations: ImageAnnotation[] = [];
// Parse annotation attributes
for (const attr of attributes) {
if (attr.name.startsWith(this.ANNOTATION_PREFIX)) {
try {
const annotationData = JSON.parse(attr.value);
annotations.push({
...annotationData,
id: attr.attributeId,
noteId: noteId,
created: new Date(annotationData.created),
modified: annotationData.modified ? new Date(annotationData.modified) : undefined
});
} catch (error) {
console.error('Failed to parse annotation:', error);
}
}
}
// Sort by creation date
annotations.sort((a, b) => a.created.getTime() - b.created.getTime());
this.activeAnnotations.set(noteId, annotations);
return annotations;
}) || [];
}
/**
* Save a new annotation
*/
async saveAnnotation(annotation: Omit<ImageAnnotation, 'id' | 'created'>): Promise<ImageAnnotation> {
return await withErrorBoundary(async () => {
// Validate annotation data
if (!annotation.text || !annotation.noteId) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid annotation data'
);
}
// Sanitize text
annotation.text = this.sanitizeText(annotation.text);
const note = await froca.getNote(annotation.noteId);
if (!note) {
throw new Error('Note not found');
}
const newAnnotation: ImageAnnotation = {
...annotation,
id: this.generateId(),
created: new Date()
};
// Save as note attribute
const attributeName = `${this.ANNOTATION_PREFIX}_${newAnnotation.id}`;
const attributeValue = JSON.stringify({
x: newAnnotation.x,
y: newAnnotation.y,
text: newAnnotation.text,
author: newAnnotation.author,
created: newAnnotation.created.toISOString(),
color: newAnnotation.color,
icon: newAnnotation.icon,
type: newAnnotation.type,
width: newAnnotation.width,
height: newAnnotation.height
});
await server.put(`notes/${annotation.noteId}/attributes`, {
attributes: [{
type: 'label',
name: attributeName,
value: attributeValue
}]
});
// Update cache
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
annotations.push(newAnnotation);
this.activeAnnotations.set(annotation.noteId, annotations);
return newAnnotation;
}) as Promise<ImageAnnotation>;
}
/**
* Update an existing annotation
*/
async updateAnnotation(annotation: ImageAnnotation): Promise<void> {
try {
const note = await froca.getNote(annotation.noteId);
if (!note) {
throw new Error('Note not found');
}
annotation.modified = new Date();
// Update attribute
const attributeName = `${this.ANNOTATION_PREFIX}_${annotation.id}`;
const attributeValue = JSON.stringify({
x: annotation.x,
y: annotation.y,
text: annotation.text,
author: annotation.author,
created: annotation.created.toISOString(),
modified: annotation.modified.toISOString(),
color: annotation.color,
icon: annotation.icon,
type: annotation.type,
width: annotation.width,
height: annotation.height
});
// Find and update the attribute
const attributes = note.getAttributes();
const attr = attributes.find(a => a.name === attributeName);
if (attr) {
await server.put(`notes/${annotation.noteId}/attributes/${attr.attributeId}`, {
value: attributeValue
});
}
// Update cache
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
const index = annotations.findIndex(a => a.id === annotation.id);
if (index !== -1) {
annotations[index] = annotation;
this.activeAnnotations.set(annotation.noteId, annotations);
}
} catch (error) {
console.error('Failed to update annotation:', error);
throw error;
}
}
/**
* Delete an annotation
*/
async deleteAnnotation(noteId: string, annotationId: string): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note) return;
const attributeName = `${this.ANNOTATION_PREFIX}_${annotationId}`;
const attributes = note.getAttributes();
const attr = attributes.find(a => a.name === attributeName);
if (attr) {
await server.remove(`notes/${noteId}/attributes/${attr.attributeId}`);
}
// Update cache
const annotations = this.activeAnnotations.get(noteId) || [];
const filtered = annotations.filter(a => a.id !== annotationId);
this.activeAnnotations.set(noteId, filtered);
// Remove element if exists
const element = this.annotationElements.get(annotationId);
if (element) {
element.remove();
this.annotationElements.delete(annotationId);
}
} catch (error) {
console.error('Failed to delete annotation:', error);
throw error;
}
}
/**
* Render annotations on an image container
*/
renderAnnotations(container: HTMLElement, noteId: string, imageElement: HTMLImageElement): void {
const annotations = this.activeAnnotations.get(noteId) || [];
// Clear existing annotation elements
this.clearAnnotationElements();
// Create annotation overlay container
const overlay = this.createOverlayContainer(container, imageElement);
// Render each annotation
annotations.forEach(annotation => {
const element = this.createAnnotationElement(annotation, overlay);
this.annotationElements.set(annotation.id, element);
});
// Add click handler for creating new annotations
if (this.config.allowEditing && this.isEditMode) {
this.setupAnnotationCreation(overlay, noteId);
}
// Add ARIA attributes for accessibility
overlay.setAttribute('role', 'img');
overlay.setAttribute('aria-label', 'Image with annotations');
}
/**
* Create overlay container for annotations
*/
private createOverlayContainer(container: HTMLElement, imageElement: HTMLImageElement): HTMLElement {
let overlay = container.querySelector('.annotation-overlay') as HTMLElement;
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'annotation-overlay';
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: ${this.isEditMode ? 'auto' : 'none'};
z-index: 10;
`;
// Position overlay over the image
const rect = imageElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
overlay.style.top = `${rect.top - containerRect.top}px`;
overlay.style.left = `${rect.left - containerRect.left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
container.appendChild(overlay);
}
return overlay;
}
/**
* Create annotation element
*/
private createAnnotationElement(annotation: ImageAnnotation, container: HTMLElement): HTMLElement {
const element = document.createElement('div');
element.className = `annotation-marker annotation-${annotation.type || 'comment'}`;
element.dataset.annotationId = annotation.id;
// Position based on percentage
element.style.cssText = `
position: absolute;
left: ${annotation.x}%;
top: ${annotation.y}%;
transform: translate(-50%, -50%);
cursor: pointer;
z-index: 20;
pointer-events: auto;
`;
// Create marker based on type
if (annotation.type === 'region') {
// Region annotation
element.style.cssText += `
width: ${annotation.width || 20}%;
height: ${annotation.height || 20}%;
border: 2px solid ${annotation.color || this.config.defaultColor};
background: ${annotation.color || this.config.defaultColor}33;
border-radius: 4px;
`;
} else {
// Point annotation
const marker = document.createElement('div');
marker.style.cssText = `
width: 24px;
height: 24px;
background: ${annotation.color || this.config.defaultColor};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
const icon = document.createElement('i');
icon.className = `bx ${annotation.icon || this.config.defaultIcon}`;
icon.style.cssText = `
color: #333;
font-size: 14px;
`;
marker.appendChild(icon);
element.appendChild(marker);
// Add ARIA attributes for accessibility
element.setAttribute('role', 'button');
element.setAttribute('aria-label', `Annotation: ${this.sanitizeText(annotation.text)}`);
element.setAttribute('tabindex', '0');
}
// Add tooltip
const tooltip = document.createElement('div');
tooltip.className = 'annotation-tooltip';
tooltip.style.cssText = `
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
max-width: 200px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
margin-bottom: 8px;
`;
// Use textContent to prevent XSS
tooltip.textContent = this.sanitizeText(annotation.text);
element.appendChild(tooltip);
// Show tooltip on hover
element.addEventListener('mouseenter', () => {
tooltip.style.opacity = '1';
});
element.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0';
});
// Handle click for editing
element.addEventListener('click', (e) => {
e.stopPropagation();
this.selectAnnotation(annotation);
});
container.appendChild(element);
return element;
}
/**
* Setup annotation creation on click
*/
private setupAnnotationCreation(overlay: HTMLElement, noteId: string): void {
overlay.addEventListener('click', async (e) => {
if (!this.isEditMode) return;
const rect = overlay.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Show annotation creation dialog
const text = prompt('Enter annotation text:');
if (text) {
await this.saveAnnotation({
noteId,
x,
y,
text,
author: 'current_user', // TODO: Get from session
type: 'comment'
});
// Reload annotations
await this.loadAnnotations(noteId);
// Re-render
const imageElement = overlay.parentElement?.querySelector('img') as HTMLImageElement;
if (imageElement && overlay.parentElement) {
this.renderAnnotations(overlay.parentElement, noteId, imageElement);
}
}
});
}
/**
* Select an annotation for editing
*/
private selectAnnotation(annotation: ImageAnnotation): void {
this.selectedAnnotation = annotation;
// Highlight selected annotation
this.annotationElements.forEach((element, id) => {
if (id === annotation.id) {
element.classList.add('selected');
element.style.outline = '2px solid #2196F3';
} else {
element.classList.remove('selected');
element.style.outline = 'none';
}
});
// Show edit options
if (this.isEditMode) {
this.showEditDialog(annotation);
}
}
/**
* Show edit dialog for annotation
*/
private showEditDialog(annotation: ImageAnnotation): void {
// Simple implementation - could be replaced with a proper modal
const newText = prompt('Edit annotation:', annotation.text);
if (newText !== null) {
annotation.text = newText;
this.updateAnnotation(annotation);
// Update tooltip with sanitized text
const element = this.annotationElements.get(annotation.id);
if (element) {
const tooltip = element.querySelector('.annotation-tooltip');
if (tooltip) {
// Use textContent to prevent XSS
tooltip.textContent = this.sanitizeText(newText);
}
}
}
}
/**
* Toggle edit mode
*/
toggleEditMode(): void {
this.isEditMode = !this.isEditMode;
// Update overlay pointer events
document.querySelectorAll('.annotation-overlay').forEach(overlay => {
(overlay as HTMLElement).style.pointerEvents = this.isEditMode ? 'auto' : 'none';
});
}
/**
* Clear all annotation elements
*/
private clearAnnotationElements(): void {
this.annotationElements.forEach(element => element.remove());
this.annotationElements.clear();
}
/**
* Generate unique ID
*/
private generateId(): string {
return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Export annotations as JSON
*/
exportAnnotations(noteId: string): string {
const annotations = this.activeAnnotations.get(noteId) || [];
return JSON.stringify(annotations, null, 2);
}
/**
* Import annotations from JSON
*/
async importAnnotations(noteId: string, json: string): Promise<void> {
try {
const annotations = JSON.parse(json) as ImageAnnotation[];
for (const annotation of annotations) {
await this.saveAnnotation({
noteId,
x: annotation.x,
y: annotation.y,
text: annotation.text,
author: annotation.author,
color: annotation.color,
icon: annotation.icon,
type: annotation.type,
width: annotation.width,
height: annotation.height
});
}
await this.loadAnnotations(noteId);
} catch (error) {
console.error('Failed to import annotations:', error);
throw error;
}
}
/**
* Sanitize text to prevent XSS
*/
private sanitizeText(text: string): string {
if (!text) return '';
// Remove any HTML tags and dangerous characters
const div = document.createElement('div');
div.textContent = text;
// Additional validation
const sanitized = div.textContent || '';
// Remove any remaining special characters that could be dangerous
return sanitized
.replace(/<script[^>]*>.*?<\/script>/gi, '')
.replace(/<iframe[^>]*>.*?<\/iframe>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '');
}
/**
* Cleanup resources
*/
cleanup(): void {
this.clearAnnotationElements();
this.activeAnnotations.clear();
this.selectedAnnotation = null;
this.isEditMode = false;
}
}
export default ImageAnnotationsService.getInstance();

View File

@@ -1,877 +0,0 @@
/**
* Image Comparison Module for Trilium Notes
* Provides side-by-side and overlay comparison modes for images
*/
import mediaViewer from './media_viewer.js';
import utils from './utils.js';
/**
* Comparison mode types
*/
export type ComparisonMode = 'side-by-side' | 'overlay' | 'swipe' | 'difference';
/**
* Image comparison configuration
*/
export interface ComparisonConfig {
mode: ComparisonMode;
syncZoom: boolean;
syncPan: boolean;
showLabels: boolean;
swipePosition?: number; // For swipe mode (0-100)
opacity?: number; // For overlay mode (0-1)
highlightDifferences?: boolean; // For difference mode
}
/**
* Comparison state
*/
interface ComparisonState {
leftImage: ComparisonImage;
rightImage: ComparisonImage;
config: ComparisonConfig;
container?: HTMLElement;
isActive: boolean;
}
/**
* Image data for comparison
*/
export interface ComparisonImage {
src: string;
title?: string;
noteId?: string;
width?: number;
height?: number;
}
/**
* ImageComparisonService provides various comparison modes for images
*/
class ImageComparisonService {
private static instance: ImageComparisonService;
private currentComparison: ComparisonState | null = null;
private comparisonContainer?: HTMLElement;
private leftCanvas?: HTMLCanvasElement;
private rightCanvas?: HTMLCanvasElement;
private leftContext?: CanvasRenderingContext2D;
private rightContext?: CanvasRenderingContext2D;
private swipeHandle?: HTMLElement;
private isDraggingSwipe: boolean = false;
private currentZoom: number = 1;
private panX: number = 0;
private panY: number = 0;
private defaultConfig: ComparisonConfig = {
mode: 'side-by-side',
syncZoom: true,
syncPan: true,
showLabels: true,
swipePosition: 50,
opacity: 0.5,
highlightDifferences: false
};
private constructor() {}
static getInstance(): ImageComparisonService {
if (!ImageComparisonService.instance) {
ImageComparisonService.instance = new ImageComparisonService();
}
return ImageComparisonService.instance;
}
/**
* Start image comparison
*/
async startComparison(
leftImage: ComparisonImage,
rightImage: ComparisonImage,
container: HTMLElement,
config?: Partial<ComparisonConfig>
): Promise<void> {
try {
// Close any existing comparison
this.closeComparison();
// Merge configuration
const finalConfig = { ...this.defaultConfig, ...config };
// Initialize state
this.currentComparison = {
leftImage,
rightImage,
config: finalConfig,
container,
isActive: true
};
// Load images
await this.loadImages(leftImage, rightImage);
// Create comparison UI based on mode
switch (finalConfig.mode) {
case 'side-by-side':
await this.createSideBySideComparison(container);
break;
case 'overlay':
await this.createOverlayComparison(container);
break;
case 'swipe':
await this.createSwipeComparison(container);
break;
case 'difference':
await this.createDifferenceComparison(container);
break;
}
// Add controls
this.addComparisonControls(container);
} catch (error) {
console.error('Failed to start image comparison:', error);
this.closeComparison();
throw error;
}
}
/**
* Load images and get dimensions
*/
private async loadImages(leftImage: ComparisonImage, rightImage: ComparisonImage): Promise<void> {
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
};
const [leftImg, rightImg] = await Promise.all([
loadImage(leftImage.src),
loadImage(rightImage.src)
]);
// Update dimensions
leftImage.width = leftImg.naturalWidth;
leftImage.height = leftImg.naturalHeight;
rightImage.width = rightImg.naturalWidth;
rightImage.height = rightImg.naturalHeight;
}
/**
* Create side-by-side comparison
*/
private async createSideBySideComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
// Clear container
container.innerHTML = '';
container.style.cssText = `
display: flex;
width: 100%;
height: 100%;
position: relative;
background: #1a1a1a;
`;
// Create left panel
const leftPanel = document.createElement('div');
leftPanel.className = 'comparison-panel comparison-left';
leftPanel.style.cssText = `
flex: 1;
position: relative;
overflow: hidden;
border-right: 2px solid #333;
`;
// Create right panel
const rightPanel = document.createElement('div');
rightPanel.className = 'comparison-panel comparison-right';
rightPanel.style.cssText = `
flex: 1;
position: relative;
overflow: hidden;
`;
// Add images
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
leftPanel.appendChild(leftImg);
rightPanel.appendChild(rightImg);
// Add labels if enabled
if (this.currentComparison.config.showLabels) {
this.addImageLabel(leftPanel, this.currentComparison.leftImage.title || 'Image 1');
this.addImageLabel(rightPanel, this.currentComparison.rightImage.title || 'Image 2');
}
container.appendChild(leftPanel);
container.appendChild(rightPanel);
// Setup synchronized zoom and pan if enabled
if (this.currentComparison.config.syncZoom || this.currentComparison.config.syncPan) {
this.setupSynchronizedControls(leftPanel, rightPanel);
}
}
/**
* Create overlay comparison
*/
private async createOverlayComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
`;
// Create base image
const baseImg = await this.createImageElement(this.currentComparison.leftImage);
baseImg.style.position = 'absolute';
baseImg.style.zIndex = '1';
// Create overlay image
const overlayImg = await this.createImageElement(this.currentComparison.rightImage);
overlayImg.style.position = 'absolute';
overlayImg.style.zIndex = '2';
overlayImg.style.opacity = String(this.currentComparison.config.opacity || 0.5);
container.appendChild(baseImg);
container.appendChild(overlayImg);
// Add opacity slider
this.addOpacityControl(container, overlayImg);
// Add labels
if (this.currentComparison.config.showLabels) {
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
display: flex;
gap: 10px;
`;
const baseLabel = this.createLabel(this.currentComparison.leftImage.title || 'Base', '#4CAF50');
const overlayLabel = this.createLabel(this.currentComparison.rightImage.title || 'Overlay', '#2196F3');
labelContainer.appendChild(baseLabel);
labelContainer.appendChild(overlayLabel);
container.appendChild(labelContainer);
}
}
/**
* Create swipe comparison
*/
private async createSwipeComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
cursor: ew-resize;
`;
// Create images
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
leftImg.style.position = 'absolute';
leftImg.style.zIndex = '1';
// Create clipping container for right image
const clipContainer = document.createElement('div');
clipContainer.className = 'swipe-clip-container';
clipContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: ${this.currentComparison.config.swipePosition}%;
height: 100%;
overflow: hidden;
z-index: 2;
`;
rightImg.style.position = 'absolute';
clipContainer.appendChild(rightImg);
// Create swipe handle
this.swipeHandle = document.createElement('div');
this.swipeHandle.className = 'swipe-handle';
this.swipeHandle.style.cssText = `
position: absolute;
top: 0;
left: ${this.currentComparison.config.swipePosition}%;
width: 4px;
height: 100%;
background: white;
cursor: ew-resize;
z-index: 3;
transform: translateX(-50%);
box-shadow: 0 0 10px rgba(0,0,0,0.5);
`;
// Add handle icon
const handleIcon = document.createElement('div');
handleIcon.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
`;
handleIcon.innerHTML = '<i class="bx bx-move-horizontal" style="font-size: 24px; color: #333;"></i>';
this.swipeHandle.appendChild(handleIcon);
container.appendChild(leftImg);
container.appendChild(clipContainer);
container.appendChild(this.swipeHandle);
// Setup swipe interaction
this.setupSwipeInteraction(container, clipContainer);
// Add labels
if (this.currentComparison.config.showLabels) {
this.addSwipeLabels(container);
}
}
/**
* Create difference comparison using canvas
*/
private async createDifferenceComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
`;
// Create canvas for difference visualization
const canvas = document.createElement('canvas');
canvas.className = 'difference-canvas';
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
// Load images
const leftImg = new Image();
const rightImg = new Image();
await Promise.all([
new Promise((resolve) => {
leftImg.onload = resolve;
leftImg.src = this.currentComparison!.leftImage.src;
}),
new Promise((resolve) => {
rightImg.onload = resolve;
rightImg.src = this.currentComparison!.rightImage.src;
})
]);
// Set canvas size
const maxWidth = Math.max(leftImg.width, rightImg.width);
const maxHeight = Math.max(leftImg.height, rightImg.height);
canvas.width = maxWidth;
canvas.height = maxHeight;
// Calculate difference
this.calculateImageDifference(ctx, leftImg, rightImg, maxWidth, maxHeight);
// Style canvas
canvas.style.cssText = `
max-width: 100%;
max-height: 100%;
object-fit: contain;
`;
container.appendChild(canvas);
// Add difference statistics
this.addDifferenceStatistics(container, ctx, maxWidth, maxHeight);
}
/**
* Calculate and visualize image difference
*/
private calculateImageDifference(
ctx: CanvasRenderingContext2D,
leftImg: HTMLImageElement,
rightImg: HTMLImageElement,
width: number,
height: number
): void {
// Draw left image
ctx.drawImage(leftImg, 0, 0, width, height);
const leftData = ctx.getImageData(0, 0, width, height);
// Draw right image
ctx.clearRect(0, 0, width, height);
ctx.drawImage(rightImg, 0, 0, width, height);
const rightData = ctx.getImageData(0, 0, width, height);
// Calculate difference
const diffData = ctx.createImageData(width, height);
let totalDiff = 0;
for (let i = 0; i < leftData.data.length; i += 4) {
const rDiff = Math.abs(leftData.data[i] - rightData.data[i]);
const gDiff = Math.abs(leftData.data[i + 1] - rightData.data[i + 1]);
const bDiff = Math.abs(leftData.data[i + 2] - rightData.data[i + 2]);
const avgDiff = (rDiff + gDiff + bDiff) / 3;
totalDiff += avgDiff;
if (this.currentComparison?.config.highlightDifferences && avgDiff > 30) {
// Highlight differences in red
diffData.data[i] = 255; // Red
diffData.data[i + 1] = 0; // Green
diffData.data[i + 2] = 0; // Blue
diffData.data[i + 3] = Math.min(255, avgDiff * 2); // Alpha based on difference
} else {
// Show original image with reduced opacity for non-different areas
diffData.data[i] = leftData.data[i];
diffData.data[i + 1] = leftData.data[i + 1];
diffData.data[i + 2] = leftData.data[i + 2];
diffData.data[i + 3] = avgDiff > 10 ? 255 : 128;
}
}
ctx.putImageData(diffData, 0, 0);
}
/**
* Add difference statistics overlay
*/
private addDifferenceStatistics(
container: HTMLElement,
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
const imageData = ctx.getImageData(0, 0, width, height);
let changedPixels = 0;
const threshold = 30;
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
if (r > threshold || g > threshold || b > threshold) {
changedPixels++;
}
}
const totalPixels = width * height;
const changePercentage = ((changedPixels / totalPixels) * 100).toFixed(2);
const statsDiv = document.createElement('div');
statsDiv.className = 'difference-stats';
statsDiv.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
statsDiv.innerHTML = `
<div><strong>Difference Analysis</strong></div>
<div>Changed pixels: ${changedPixels.toLocaleString()}</div>
<div>Total pixels: ${totalPixels.toLocaleString()}</div>
<div>Difference: ${changePercentage}%</div>
`;
container.appendChild(statsDiv);
}
/**
* Create image element
*/
private async createImageElement(image: ComparisonImage): Promise<HTMLImageElement> {
const img = document.createElement('img');
img.src = image.src;
img.alt = image.title || 'Comparison image';
img.style.cssText = `
width: 100%;
height: 100%;
object-fit: contain;
`;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
return img;
}
/**
* Add image label
*/
private addImageLabel(container: HTMLElement, text: string): void {
const label = document.createElement('div');
label.className = 'image-label';
label.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
label.textContent = text;
container.appendChild(label);
}
/**
* Create label element
*/
private createLabel(text: string, color: string): HTMLElement {
const label = document.createElement('div');
label.style.cssText = `
background: ${color};
color: white;
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
`;
label.textContent = text;
return label;
}
/**
* Add swipe labels
*/
private addSwipeLabels(container: HTMLElement): void {
if (!this.currentComparison) return;
const leftLabel = document.createElement('div');
leftLabel.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(76, 175, 80, 0.9);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
leftLabel.textContent = this.currentComparison.leftImage.title || 'Left';
const rightLabel = document.createElement('div');
rightLabel.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(33, 150, 243, 0.9);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
rightLabel.textContent = this.currentComparison.rightImage.title || 'Right';
container.appendChild(leftLabel);
container.appendChild(rightLabel);
}
/**
* Setup swipe interaction
*/
private setupSwipeInteraction(container: HTMLElement, clipContainer: HTMLElement): void {
if (!this.swipeHandle) return;
let startX = 0;
let startPosition = this.currentComparison?.config.swipePosition || 50;
const handleMouseMove = (e: MouseEvent) => {
if (!this.isDraggingSwipe) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
clipContainer.style.width = `${percentage}%`;
if (this.swipeHandle) {
this.swipeHandle.style.left = `${percentage}%`;
}
if (this.currentComparison) {
this.currentComparison.config.swipePosition = percentage;
}
};
const handleMouseUp = () => {
this.isDraggingSwipe = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
container.style.cursor = 'default';
};
this.swipeHandle.addEventListener('mousedown', (e) => {
this.isDraggingSwipe = true;
startX = e.clientX;
startPosition = this.currentComparison?.config.swipePosition || 50;
container.style.cursor = 'ew-resize';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
// Also allow dragging anywhere in the container
container.addEventListener('mousedown', (e) => {
if (e.target === this.swipeHandle || (e.target as HTMLElement).parentElement === this.swipeHandle) {
return;
}
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
clipContainer.style.width = `${percentage}%`;
if (this.swipeHandle) {
this.swipeHandle.style.left = `${percentage}%`;
}
if (this.currentComparison) {
this.currentComparison.config.swipePosition = percentage;
}
});
}
/**
* Add opacity control for overlay mode
*/
private addOpacityControl(container: HTMLElement, overlayImg: HTMLImageElement): void {
const control = document.createElement('div');
control.className = 'opacity-control';
control.style.cssText = `
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 10px 20px;
border-radius: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
`;
const label = document.createElement('label');
label.textContent = 'Opacity:';
label.style.color = 'white';
label.style.fontSize = '12px';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = String((this.currentComparison?.config.opacity || 0.5) * 100);
slider.style.width = '150px';
const value = document.createElement('span');
value.textContent = `${slider.value}%`;
value.style.color = 'white';
value.style.fontSize = '12px';
value.style.minWidth = '35px';
slider.addEventListener('input', () => {
const opacity = parseInt(slider.value) / 100;
overlayImg.style.opacity = String(opacity);
value.textContent = `${slider.value}%`;
if (this.currentComparison) {
this.currentComparison.config.opacity = opacity;
}
});
control.appendChild(label);
control.appendChild(slider);
control.appendChild(value);
container.appendChild(control);
}
/**
* Setup synchronized controls for side-by-side mode
*/
private setupSynchronizedControls(leftPanel: HTMLElement, rightPanel: HTMLElement): void {
const leftImg = leftPanel.querySelector('img') as HTMLImageElement;
const rightImg = rightPanel.querySelector('img') as HTMLImageElement;
if (!leftImg || !rightImg) return;
// Synchronize scroll
if (this.currentComparison?.config.syncPan) {
leftPanel.addEventListener('scroll', () => {
rightPanel.scrollLeft = leftPanel.scrollLeft;
rightPanel.scrollTop = leftPanel.scrollTop;
});
rightPanel.addEventListener('scroll', () => {
leftPanel.scrollLeft = rightPanel.scrollLeft;
leftPanel.scrollTop = rightPanel.scrollTop;
});
}
// Synchronize zoom with wheel events
if (this.currentComparison?.config.syncZoom) {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 1.1 : 0.9;
this.currentZoom = Math.max(0.5, Math.min(5, this.currentZoom * delta));
leftImg.style.transform = `scale(${this.currentZoom})`;
rightImg.style.transform = `scale(${this.currentZoom})`;
};
leftPanel.addEventListener('wheel', handleWheel);
rightPanel.addEventListener('wheel', handleWheel);
}
}
/**
* Add comparison controls toolbar
*/
private addComparisonControls(container: HTMLElement): void {
const toolbar = document.createElement('div');
toolbar.className = 'comparison-toolbar';
toolbar.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
padding: 8px;
display: flex;
gap: 8px;
z-index: 100;
`;
// Mode switcher
const modes: ComparisonMode[] = ['side-by-side', 'overlay', 'swipe', 'difference'];
modes.forEach(mode => {
const btn = document.createElement('button');
btn.className = `mode-btn mode-${mode}`;
btn.style.cssText = `
background: ${this.currentComparison?.config.mode === mode ? '#2196F3' : 'rgba(255,255,255,0.1)'};
color: white;
border: none;
padding: 6px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
`;
btn.textContent = mode.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase());
btn.addEventListener('click', async () => {
if (this.currentComparison && this.currentComparison.container) {
this.currentComparison.config.mode = mode;
await this.startComparison(
this.currentComparison.leftImage,
this.currentComparison.rightImage,
this.currentComparison.container,
this.currentComparison.config
);
}
});
toolbar.appendChild(btn);
});
// Close button
const closeBtn = document.createElement('button');
closeBtn.style.cssText = `
background: rgba(255,0,0,0.5);
color: white;
border: none;
padding: 6px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
margin-left: 10px;
`;
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => this.closeComparison());
toolbar.appendChild(closeBtn);
container.appendChild(toolbar);
}
/**
* Close comparison
*/
closeComparison(): void {
if (this.currentComparison?.container) {
this.currentComparison.container.innerHTML = '';
}
this.currentComparison = null;
this.comparisonContainer = undefined;
this.leftCanvas = undefined;
this.rightCanvas = undefined;
this.leftContext = undefined;
this.rightContext = undefined;
this.swipeHandle = undefined;
this.isDraggingSwipe = false;
this.currentZoom = 1;
this.panX = 0;
this.panY = 0;
}
/**
* Check if comparison is active
*/
isComparisonActive(): boolean {
return this.currentComparison?.isActive || false;
}
/**
* Get current comparison state
*/
getComparisonState(): ComparisonState | null {
return this.currentComparison;
}
}
export default ImageComparisonService.getInstance();

View File

@@ -1,874 +0,0 @@
/**
* Basic Image Editor Module for Trilium Notes
* Provides non-destructive image editing capabilities
*/
import server from './server.js';
import toastService from './toast.js';
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Edit operation types
*/
export type EditOperation =
| 'rotate'
| 'crop'
| 'brightness'
| 'contrast'
| 'saturation'
| 'blur'
| 'sharpen';
/**
* Edit history entry
*/
export interface EditHistoryEntry {
operation: EditOperation;
params: any;
timestamp: Date;
}
/**
* Crop area definition
*/
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
/**
* Editor state
*/
interface EditorState {
originalImage: HTMLImageElement | null;
currentImage: HTMLImageElement | null;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
history: EditHistoryEntry[];
historyIndex: number;
isEditing: boolean;
}
/**
* Filter parameters
*/
export interface FilterParams {
brightness?: number; // -100 to 100
contrast?: number; // -100 to 100
saturation?: number; // -100 to 100
blur?: number; // 0 to 20
sharpen?: number; // 0 to 100
}
/**
* ImageEditorService provides basic image editing capabilities
*/
class ImageEditorService {
private static instance: ImageEditorService;
private editorState: EditorState;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
private cropOverlay?: HTMLElement;
private cropHandles?: HTMLElement[];
private cropArea: CropArea | null = null;
private isDraggingCrop: boolean = false;
private dragStartX: number = 0;
private dragStartY: number = 0;
private currentFilters: FilterParams = {};
// Canvas size limits for security and memory management
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
private constructor() {
// Initialize canvases
this.editorState = {
originalImage: null,
currentImage: null,
canvas: document.createElement('canvas'),
context: null as any,
history: [],
historyIndex: -1,
isEditing: false
};
const ctx = this.editorState.canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
this.editorState.context = ctx;
this.tempCanvas = document.createElement('canvas');
const tempCtx = this.tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('Failed to get temp canvas context');
}
this.tempContext = tempCtx;
}
static getInstance(): ImageEditorService {
if (!ImageEditorService.instance) {
ImageEditorService.instance = new ImageEditorService();
}
return ImageEditorService.instance;
}
/**
* Start editing an image
*/
async startEditing(src: string | HTMLImageElement): Promise<HTMLCanvasElement> {
return await withErrorBoundary(async () => {
// Validate input
if (typeof src === 'string') {
ImageValidator.validateUrl(src);
}
// Load image
let img: HTMLImageElement;
if (typeof src === 'string') {
img = await this.loadImage(src);
} else {
img = src;
}
// Validate image dimensions
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
// Check memory availability
const estimatedMemory = MemoryMonitor.estimateImageMemory(img.naturalWidth, img.naturalHeight);
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
throw new ImageError(
ImageErrorType.MEMORY_ERROR,
'Insufficient memory to process image',
{ estimatedMemory }
);
}
if (img.naturalWidth > this.MAX_CANVAS_SIZE ||
img.naturalHeight > this.MAX_CANVAS_SIZE ||
img.naturalWidth * img.naturalHeight > this.MAX_CANVAS_AREA) {
// Scale down if too large
const scale = Math.min(
this.MAX_CANVAS_SIZE / Math.max(img.naturalWidth, img.naturalHeight),
Math.sqrt(this.MAX_CANVAS_AREA / (img.naturalWidth * img.naturalHeight))
);
const scaledWidth = Math.floor(img.naturalWidth * scale);
const scaledHeight = Math.floor(img.naturalHeight * scale);
console.warn(`Image too large (${img.naturalWidth}x${img.naturalHeight}), scaling to ${scaledWidth}x${scaledHeight}`);
// Create scaled image
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = scaledWidth;
scaledCanvas.height = scaledHeight;
const scaledCtx = scaledCanvas.getContext('2d');
if (!scaledCtx) throw new Error('Failed to get scaled canvas context');
scaledCtx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
// Create new image from scaled canvas
const scaledImg = new Image();
scaledImg.src = scaledCanvas.toDataURL();
await new Promise(resolve => scaledImg.onload = resolve);
img = scaledImg;
// Clean up scaled canvas
scaledCanvas.width = 0;
scaledCanvas.height = 0;
}
// Store original
this.editorState.originalImage = img;
this.editorState.currentImage = img;
this.editorState.isEditing = true;
this.editorState.history = [];
this.editorState.historyIndex = -1;
this.currentFilters = {};
// Setup canvas with validated dimensions
this.editorState.canvas.width = img.naturalWidth;
this.editorState.canvas.height = img.naturalHeight;
this.editorState.context.drawImage(img, 0, 0);
return this.editorState.canvas;
}, (error) => {
this.stopEditing();
throw error;
}) || this.editorState.canvas;
}
/**
* Rotate image by degrees (90, 180, 270)
*/
rotate(degrees: 90 | 180 | 270 | -90): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
const { width, height } = canvas;
// Setup temp canvas
if (degrees === 90 || degrees === -90 || degrees === 270) {
this.tempCanvas.width = height;
this.tempCanvas.height = width;
} else {
this.tempCanvas.width = width;
this.tempCanvas.height = height;
}
// Clear temp canvas
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
// Rotate
this.tempContext.save();
if (degrees === 90) {
this.tempContext.translate(height, 0);
this.tempContext.rotate(Math.PI / 2);
} else if (degrees === 180) {
this.tempContext.translate(width, height);
this.tempContext.rotate(Math.PI);
} else if (degrees === 270 || degrees === -90) {
this.tempContext.translate(0, width);
this.tempContext.rotate(-Math.PI / 2);
}
this.tempContext.drawImage(canvas, 0, 0);
this.tempContext.restore();
// Copy back to main canvas
canvas.width = this.tempCanvas.width;
canvas.height = this.tempCanvas.height;
context.drawImage(this.tempCanvas, 0, 0);
// Add to history
this.addToHistory('rotate', { degrees });
}
/**
* Start crop selection
*/
startCrop(container: HTMLElement): void {
if (!this.editorState.isEditing) return;
// Create crop overlay
this.cropOverlay = document.createElement('div');
this.cropOverlay.className = 'crop-overlay';
this.cropOverlay.style.cssText = `
position: absolute;
border: 2px dashed #fff;
background: rgba(0, 0, 0, 0.3);
cursor: move;
z-index: 1000;
`;
// Create resize handles
this.cropHandles = [];
const handlePositions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
handlePositions.forEach(pos => {
const handle = document.createElement('div');
handle.className = `crop-handle crop-handle-${pos}`;
handle.dataset.position = pos;
handle.style.cssText = `
position: absolute;
width: 10px;
height: 10px;
background: white;
border: 1px solid #333;
z-index: 1001;
`;
// Position handles
switch (pos) {
case 'nw':
handle.style.top = '-5px';
handle.style.left = '-5px';
handle.style.cursor = 'nw-resize';
break;
case 'n':
handle.style.top = '-5px';
handle.style.left = '50%';
handle.style.transform = 'translateX(-50%)';
handle.style.cursor = 'n-resize';
break;
case 'ne':
handle.style.top = '-5px';
handle.style.right = '-5px';
handle.style.cursor = 'ne-resize';
break;
case 'e':
handle.style.top = '50%';
handle.style.right = '-5px';
handle.style.transform = 'translateY(-50%)';
handle.style.cursor = 'e-resize';
break;
case 'se':
handle.style.bottom = '-5px';
handle.style.right = '-5px';
handle.style.cursor = 'se-resize';
break;
case 's':
handle.style.bottom = '-5px';
handle.style.left = '50%';
handle.style.transform = 'translateX(-50%)';
handle.style.cursor = 's-resize';
break;
case 'sw':
handle.style.bottom = '-5px';
handle.style.left = '-5px';
handle.style.cursor = 'sw-resize';
break;
case 'w':
handle.style.top = '50%';
handle.style.left = '-5px';
handle.style.transform = 'translateY(-50%)';
handle.style.cursor = 'w-resize';
break;
}
this.cropOverlay.appendChild(handle);
this.cropHandles!.push(handle);
});
// Set initial crop area (80% of image)
const canvasRect = this.editorState.canvas.getBoundingClientRect();
const initialSize = Math.min(canvasRect.width, canvasRect.height) * 0.8;
const initialX = (canvasRect.width - initialSize) / 2;
const initialY = (canvasRect.height - initialSize) / 2;
this.cropArea = {
x: initialX,
y: initialY,
width: initialSize,
height: initialSize
};
this.updateCropOverlay();
container.appendChild(this.cropOverlay);
// Setup drag handlers
this.setupCropHandlers();
}
/**
* Setup crop interaction handlers
*/
private setupCropHandlers(): void {
if (!this.cropOverlay) return;
// Drag to move
this.cropOverlay.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).classList.contains('crop-handle')) return;
this.isDraggingCrop = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
const handleMove = (e: MouseEvent) => {
if (!this.isDraggingCrop || !this.cropArea) return;
const deltaX = e.clientX - this.dragStartX;
const deltaY = e.clientY - this.dragStartY;
this.cropArea.x += deltaX;
this.cropArea.y += deltaY;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.updateCropOverlay();
};
const handleUp = () => {
this.isDraggingCrop = false;
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleUp);
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
});
// Resize handles
this.cropHandles?.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
const position = handle.dataset.position!;
const startX = e.clientX;
const startY = e.clientY;
const startCrop = { ...this.cropArea! };
const handleResize = (e: MouseEvent) => {
if (!this.cropArea) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
switch (position) {
case 'nw':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.width = startCrop.width - deltaX;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'n':
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'ne':
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.width = startCrop.width + deltaX;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'e':
this.cropArea.width = startCrop.width + deltaX;
break;
case 'se':
this.cropArea.width = startCrop.width + deltaX;
this.cropArea.height = startCrop.height + deltaY;
break;
case 's':
this.cropArea.height = startCrop.height + deltaY;
break;
case 'sw':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.width = startCrop.width - deltaX;
this.cropArea.height = startCrop.height + deltaY;
break;
case 'w':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.width = startCrop.width - deltaX;
break;
}
// Ensure minimum size
this.cropArea.width = Math.max(50, this.cropArea.width);
this.cropArea.height = Math.max(50, this.cropArea.height);
this.updateCropOverlay();
};
const handleUp = () => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', handleUp);
};
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', handleUp);
});
});
}
/**
* Update crop overlay position
*/
private updateCropOverlay(): void {
if (!this.cropOverlay || !this.cropArea) return;
this.cropOverlay.style.left = `${this.cropArea.x}px`;
this.cropOverlay.style.top = `${this.cropArea.y}px`;
this.cropOverlay.style.width = `${this.cropArea.width}px`;
this.cropOverlay.style.height = `${this.cropArea.height}px`;
}
/**
* Apply crop
*/
applyCrop(): void {
if (!this.editorState.isEditing || !this.cropArea) return;
const { canvas, context } = this.editorState;
const canvasRect = canvas.getBoundingClientRect();
// Convert crop area from screen to canvas coordinates
const scaleX = canvas.width / canvasRect.width;
const scaleY = canvas.height / canvasRect.height;
const cropX = this.cropArea.x * scaleX;
const cropY = this.cropArea.y * scaleY;
const cropWidth = this.cropArea.width * scaleX;
const cropHeight = this.cropArea.height * scaleY;
// Get cropped image data
const imageData = context.getImageData(cropX, cropY, cropWidth, cropHeight);
// Resize canvas and put cropped image
canvas.width = cropWidth;
canvas.height = cropHeight;
context.putImageData(imageData, 0, 0);
// Clean up crop overlay
this.cancelCrop();
// Add to history
this.addToHistory('crop', {
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight
});
}
/**
* Cancel crop
*/
cancelCrop(): void {
if (this.cropOverlay) {
this.cropOverlay.remove();
this.cropOverlay = undefined;
}
this.cropHandles = undefined;
this.cropArea = null;
}
/**
* Apply brightness adjustment
*/
applyBrightness(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.brightness = value;
this.applyFilters();
}
/**
* Apply contrast adjustment
*/
applyContrast(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.contrast = value;
this.applyFilters();
}
/**
* Apply saturation adjustment
*/
applySaturation(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.saturation = value;
this.applyFilters();
}
/**
* Apply all filters
*/
private applyFilters(): void {
const { canvas, context, originalImage } = this.editorState;
if (!originalImage) return;
// Clear canvas and redraw original
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
// Get image data
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Apply brightness
if (this.currentFilters.brightness) {
const brightness = this.currentFilters.brightness * 2.55; // Convert to 0-255 range
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, data[i] + brightness));
data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + brightness));
data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + brightness));
}
}
// Apply contrast
if (this.currentFilters.contrast) {
const factor = (259 * (this.currentFilters.contrast + 255)) / (255 * (259 - this.currentFilters.contrast));
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
}
// Apply saturation
if (this.currentFilters.saturation) {
const saturation = this.currentFilters.saturation / 100;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.2989 * data[i] + 0.5870 * data[i + 1] + 0.1140 * data[i + 2];
data[i] = Math.min(255, Math.max(0, gray + saturation * (data[i] - gray)));
data[i + 1] = Math.min(255, Math.max(0, gray + saturation * (data[i + 1] - gray)));
data[i + 2] = Math.min(255, Math.max(0, gray + saturation * (data[i + 2] - gray)));
}
}
// Put modified image data back
context.putImageData(imageData, 0, 0);
}
/**
* Apply blur effect
*/
applyBlur(radius: number): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
// Use CSS filter for performance
context.filter = `blur(${radius}px)`;
context.drawImage(canvas, 0, 0);
context.filter = 'none';
this.addToHistory('blur', { radius });
}
/**
* Apply sharpen effect
*/
applySharpen(amount: number): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// Create copy of original data
const original = new Uint8ClampedArray(data);
// Sharpen kernel
const kernel = [
0, -1, 0,
-1, 5 + amount / 25, -1,
0, -1, 0
];
// Apply convolution
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = (y * width + x) * 4;
for (let c = 0; c < 3; c++) {
let sum = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const kidx = ((y + ky) * width + (x + kx)) * 4;
sum += original[kidx + c] * kernel[(ky + 1) * 3 + (kx + 1)];
}
}
data[idx + c] = Math.min(255, Math.max(0, sum));
}
}
}
context.putImageData(imageData, 0, 0);
this.addToHistory('sharpen', { amount });
}
/**
* Undo last operation
*/
undo(): void {
if (!this.editorState.isEditing || this.editorState.historyIndex <= 0) return;
this.editorState.historyIndex--;
this.replayHistory();
}
/**
* Redo operation
*/
redo(): void {
if (!this.editorState.isEditing ||
this.editorState.historyIndex >= this.editorState.history.length - 1) return;
this.editorState.historyIndex++;
this.replayHistory();
}
/**
* Replay history up to current index
*/
private replayHistory(): void {
const { canvas, context, originalImage, history, historyIndex } = this.editorState;
if (!originalImage) return;
// Reset to original
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
context.drawImage(originalImage, 0, 0);
// Replay operations
for (let i = 0; i <= historyIndex; i++) {
const entry = history[i];
// Apply operation based on entry
// Note: This is simplified - actual implementation would need to store and replay exact operations
}
}
/**
* Add operation to history
*/
private addToHistory(operation: EditOperation, params: any): void {
// Remove any operations after current index
this.editorState.history = this.editorState.history.slice(0, this.editorState.historyIndex + 1);
// Add new operation
this.editorState.history.push({
operation,
params,
timestamp: new Date()
});
this.editorState.historyIndex++;
// Limit history size
if (this.editorState.history.length > 50) {
this.editorState.history.shift();
this.editorState.historyIndex--;
}
}
/**
* Save edited image
*/
async saveImage(noteId?: string): Promise<Blob> {
if (!this.editorState.isEditing) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'No image being edited'
);
}
return new Promise((resolve, reject) => {
this.editorState.canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
if (noteId) {
// Optionally save to server
this.saveToServer(noteId, blob);
}
} else {
reject(new Error('Failed to create blob'));
}
}, 'image/png');
});
}
/**
* Save edited image to server
*/
private async saveToServer(noteId: string, blob: Blob): Promise<void> {
try {
const formData = new FormData();
formData.append('image', blob, 'edited.png');
await server.upload(`notes/${noteId}/image`, formData);
toastService.showMessage('Image saved successfully');
} catch (error) {
console.error('Failed to save image:', error);
toastService.showError('Failed to save image');
}
}
/**
* Reset to original image
*/
reset(): void {
if (!this.editorState.isEditing || !this.editorState.originalImage) return;
const { canvas, context, originalImage } = this.editorState;
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
context.drawImage(originalImage, 0, 0);
this.currentFilters = {};
this.editorState.history = [];
this.editorState.historyIndex = -1;
}
/**
* Stop editing and clean up resources
*/
stopEditing(): void {
this.cancelCrop();
// Request garbage collection after cleanup
MemoryMonitor.requestGarbageCollection();
// Clean up canvas memory
if (this.editorState.canvas) {
this.editorState.context.clearRect(0, 0, this.editorState.canvas.width, this.editorState.canvas.height);
this.editorState.canvas.width = 0;
this.editorState.canvas.height = 0;
}
if (this.tempCanvas) {
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
this.tempCanvas.width = 0;
this.tempCanvas.height = 0;
}
// Release image references
if (this.editorState.originalImage) {
this.editorState.originalImage.src = '';
}
if (this.editorState.currentImage) {
this.editorState.currentImage.src = '';
}
this.editorState.isEditing = false;
this.editorState.originalImage = null;
this.editorState.currentImage = null;
this.editorState.history = [];
this.editorState.historyIndex = -1;
this.currentFilters = {};
}
/**
* Load image from URL
*/
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/**
* Check if can undo
*/
canUndo(): boolean {
return this.editorState.historyIndex > 0;
}
/**
* Check if can redo
*/
canRedo(): boolean {
return this.editorState.historyIndex < this.editorState.history.length - 1;
}
/**
* Get current canvas
*/
getCanvas(): HTMLCanvasElement {
return this.editorState.canvas;
}
/**
* Check if editing
*/
isEditing(): boolean {
return this.editorState.isEditing;
}
}
export default ImageEditorService.getInstance();

View File

@@ -1,369 +0,0 @@
/**
* Error Handler for Image Processing Operations
* Provides error boundaries and validation for image-related operations
*/
import toastService from './toast.js';
/**
* Error types for image operations
*/
export enum ImageErrorType {
INVALID_INPUT = 'INVALID_INPUT',
SIZE_LIMIT_EXCEEDED = 'SIZE_LIMIT_EXCEEDED',
MEMORY_ERROR = 'MEMORY_ERROR',
PROCESSING_ERROR = 'PROCESSING_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
SECURITY_ERROR = 'SECURITY_ERROR'
}
/**
* Custom error class for image operations
*/
export class ImageError extends Error {
constructor(
public type: ImageErrorType,
message: string,
public details?: any
) {
super(message);
this.name = 'ImageError';
}
}
/**
* Input validation utilities
*/
export class ImageValidator {
private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
private static readonly ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp'
];
private static readonly MAX_DIMENSION = 16384;
private static readonly MAX_AREA = 100000000; // 100 megapixels
/**
* Validate file input
*/
static validateFile(file: File): void {
// Check file size
if (file.size > this.MAX_FILE_SIZE) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE / 1024 / 1024}MB`,
{ fileSize: file.size, maxSize: this.MAX_FILE_SIZE }
);
}
// Check MIME type
if (!this.ALLOWED_MIME_TYPES.includes(file.type)) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
`File type ${file.type} is not supported`,
{ fileType: file.type, allowedTypes: this.ALLOWED_MIME_TYPES }
);
}
}
/**
* Validate image dimensions
*/
static validateDimensions(width: number, height: number): void {
if (width <= 0 || height <= 0) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid image dimensions',
{ width, height }
);
}
if (width > this.MAX_DIMENSION || height > this.MAX_DIMENSION) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`Image dimensions exceed maximum allowed size of ${this.MAX_DIMENSION}px`,
{ width, height, maxDimension: this.MAX_DIMENSION }
);
}
if (width * height > this.MAX_AREA) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`Image area exceeds maximum allowed area of ${this.MAX_AREA / 1000000} megapixels`,
{ area: width * height, maxArea: this.MAX_AREA }
);
}
}
/**
* Validate URL
*/
static validateUrl(url: string): void {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:', 'data:', 'blob:'].includes(parsedUrl.protocol)) {
throw new ImageError(
ImageErrorType.SECURITY_ERROR,
`Unsupported protocol: ${parsedUrl.protocol}`,
{ url, protocol: parsedUrl.protocol }
);
}
// Additional security checks for data URLs
if (parsedUrl.protocol === 'data:') {
const [header] = url.split(',');
if (!header.includes('image/')) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Data URL does not contain image data',
{ url: url.substring(0, 100) }
);
}
}
} catch (error) {
if (error instanceof ImageError) {
throw error;
}
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid URL format',
{ url, originalError: error }
);
}
}
/**
* Sanitize filename
*/
static sanitizeFilename(filename: string): string {
// Remove path traversal attempts
filename = filename.replace(/\.\./g, '');
filename = filename.replace(/[\/\\]/g, '_');
// Remove special characters except dots and dashes
filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
// Limit length
if (filename.length > 255) {
const ext = filename.split('.').pop();
filename = filename.substring(0, 250) + '.' + ext;
}
return filename;
}
}
/**
* Error boundary wrapper for async operations
*/
export async function withErrorBoundary<T>(
operation: () => Promise<T>,
errorHandler?: (error: Error) => void
): Promise<T | null> {
try {
return await operation();
} catch (error) {
const imageError = error instanceof ImageError
? error
: new ImageError(
ImageErrorType.PROCESSING_ERROR,
error instanceof Error ? error.message : 'Unknown error occurred',
{ originalError: error }
);
// Log error
console.error('[Image Error]', imageError.type, imageError.message, imageError.details);
// Show user-friendly message
switch (imageError.type) {
case ImageErrorType.SIZE_LIMIT_EXCEEDED:
toastService.showError('Image is too large to process');
break;
case ImageErrorType.INVALID_INPUT:
toastService.showError('Invalid image or input provided');
break;
case ImageErrorType.MEMORY_ERROR:
toastService.showError('Not enough memory to process image');
break;
case ImageErrorType.SECURITY_ERROR:
toastService.showError('Security violation detected');
break;
case ImageErrorType.NETWORK_ERROR:
toastService.showError('Network error occurred');
break;
default:
toastService.showError('Failed to process image');
}
// Call custom error handler if provided
if (errorHandler) {
errorHandler(imageError);
}
return null;
}
}
/**
* Memory monitoring utilities
*/
export class MemoryMonitor {
private static readonly WARNING_THRESHOLD = 0.8; // 80% of available memory
/**
* Check if memory is available for operation
*/
static checkMemoryAvailable(estimatedBytes: number): boolean {
if ('memory' in performance && (performance as any).memory) {
const memory = (performance as any).memory;
const used = memory.usedJSHeapSize;
const limit = memory.jsHeapSizeLimit;
const available = limit - used;
if (estimatedBytes > available * this.WARNING_THRESHOLD) {
console.warn(`Memory warning: Estimated ${estimatedBytes} bytes needed, ${available} bytes available`);
return false;
}
}
return true;
}
/**
* Estimate memory needed for image
*/
static estimateImageMemory(width: number, height: number, channels: number = 4): number {
// Each pixel uses 4 bytes (RGBA) or specified channels
return width * height * channels;
}
/**
* Force garbage collection if available
*/
static requestGarbageCollection(): void {
if (typeof (globalThis as any).gc === 'function') {
(globalThis as any).gc();
}
}
}
/**
* Web Worker support for heavy operations
*/
export class ImageWorkerPool {
private workers: Worker[] = [];
private taskQueue: Array<{
data: any;
resolve: (value: any) => void;
reject: (error: any) => void;
}> = [];
private busyWorkers = new Set<Worker>();
constructor(
private workerScript: string,
private poolSize: number = navigator.hardwareConcurrency || 4
) {
this.initializeWorkers();
}
private initializeWorkers(): void {
for (let i = 0; i < this.poolSize; i++) {
try {
const worker = new Worker(this.workerScript);
worker.addEventListener('message', (e) => this.handleWorkerMessage(worker, e));
worker.addEventListener('error', (e) => this.handleWorkerError(worker, e));
this.workers.push(worker);
} catch (error) {
console.error('Failed to create worker:', error);
}
}
}
private handleWorkerMessage(worker: Worker, event: MessageEvent): void {
this.busyWorkers.delete(worker);
// Process next task if available
if (this.taskQueue.length > 0) {
const task = this.taskQueue.shift()!;
this.executeTask(worker, task);
}
}
private handleWorkerError(worker: Worker, event: ErrorEvent): void {
this.busyWorkers.delete(worker);
console.error('Worker error:', event);
}
private executeTask(
worker: Worker,
task: { data: any; resolve: (value: any) => void; reject: (error: any) => void }
): void {
this.busyWorkers.add(worker);
const messageHandler = (e: MessageEvent) => {
worker.removeEventListener('message', messageHandler);
worker.removeEventListener('error', errorHandler);
this.busyWorkers.delete(worker);
task.resolve(e.data);
// Process next task
if (this.taskQueue.length > 0) {
const nextTask = this.taskQueue.shift()!;
this.executeTask(worker, nextTask);
}
};
const errorHandler = (e: ErrorEvent) => {
worker.removeEventListener('message', messageHandler);
worker.removeEventListener('error', errorHandler);
this.busyWorkers.delete(worker);
task.reject(e);
// Process next task
if (this.taskQueue.length > 0) {
const nextTask = this.taskQueue.shift()!;
this.executeTask(worker, nextTask);
}
};
worker.addEventListener('message', messageHandler);
worker.addEventListener('error', errorHandler);
worker.postMessage(task.data);
}
async process(data: any): Promise<any> {
return new Promise((resolve, reject) => {
// Find available worker
const availableWorker = this.workers.find(w => !this.busyWorkers.has(w));
if (availableWorker) {
this.executeTask(availableWorker, { data, resolve, reject });
} else {
// Queue task
this.taskQueue.push({ data, resolve, reject });
}
});
}
terminate(): void {
this.workers.forEach(worker => worker.terminate());
this.workers = [];
this.taskQueue = [];
this.busyWorkers.clear();
}
}
export default {
ImageError,
ImageErrorType,
ImageValidator,
MemoryMonitor,
ImageWorkerPool,
withErrorBoundary
};

View File

@@ -1,839 +0,0 @@
/**
* EXIF Data Viewer Module for Trilium Notes
* Extracts and displays EXIF metadata from images
*/
/**
* EXIF data structure
*/
export interface ExifData {
// Image information
make?: string;
model?: string;
software?: string;
dateTime?: Date;
dateTimeOriginal?: Date;
dateTimeDigitized?: Date;
// Camera settings
exposureTime?: string;
fNumber?: number;
exposureProgram?: string;
iso?: number;
shutterSpeedValue?: string;
apertureValue?: number;
brightnessValue?: number;
exposureBiasValue?: number;
maxApertureValue?: number;
meteringMode?: string;
flash?: string;
focalLength?: number;
focalLengthIn35mm?: number;
// Image properties
imageWidth?: number;
imageHeight?: number;
orientation?: number;
xResolution?: number;
yResolution?: number;
resolutionUnit?: string;
colorSpace?: string;
whiteBalance?: string;
// GPS information
gpsLatitude?: number;
gpsLongitude?: number;
gpsAltitude?: number;
gpsTimestamp?: Date;
gpsSpeed?: number;
gpsDirection?: number;
// Other metadata
artist?: string;
copyright?: string;
userComment?: string;
imageDescription?: string;
lensModel?: string;
lensMake?: string;
// Raw data
raw?: Record<string, any>;
}
/**
* EXIF tag definitions
*/
const EXIF_TAGS: Record<number, string> = {
0x010F: 'make',
0x0110: 'model',
0x0131: 'software',
0x0132: 'dateTime',
0x829A: 'exposureTime',
0x829D: 'fNumber',
0x8822: 'exposureProgram',
0x8827: 'iso',
0x9003: 'dateTimeOriginal',
0x9004: 'dateTimeDigitized',
0x9201: 'shutterSpeedValue',
0x9202: 'apertureValue',
0x9203: 'brightnessValue',
0x9204: 'exposureBiasValue',
0x9205: 'maxApertureValue',
0x9207: 'meteringMode',
0x9209: 'flash',
0x920A: 'focalLength',
0xA002: 'imageWidth',
0xA003: 'imageHeight',
0x0112: 'orientation',
0x011A: 'xResolution',
0x011B: 'yResolution',
0x0128: 'resolutionUnit',
0xA001: 'colorSpace',
0xA403: 'whiteBalance',
0x8298: 'copyright',
0x013B: 'artist',
0x9286: 'userComment',
0x010E: 'imageDescription',
0xA434: 'lensModel',
0xA433: 'lensMake',
0xA432: 'focalLengthIn35mm'
};
/**
* GPS tag definitions
*/
const GPS_TAGS: Record<number, string> = {
0x0001: 'gpsLatitudeRef',
0x0002: 'gpsLatitude',
0x0003: 'gpsLongitudeRef',
0x0004: 'gpsLongitude',
0x0005: 'gpsAltitudeRef',
0x0006: 'gpsAltitude',
0x0007: 'gpsTimestamp',
0x000D: 'gpsSpeed',
0x0010: 'gpsDirection'
};
/**
* ImageExifService extracts and manages EXIF metadata from images
*/
class ImageExifService {
private static instance: ImageExifService;
private exifCache: Map<string, ExifData> = new Map();
private cacheOrder: string[] = []; // Track cache insertion order for LRU
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached entries
private readonly MAX_BUFFER_SIZE = 100 * 1024 * 1024; // 100MB max buffer size
private constructor() {}
static getInstance(): ImageExifService {
if (!ImageExifService.instance) {
ImageExifService.instance = new ImageExifService();
}
return ImageExifService.instance;
}
/**
* Extract EXIF data from image URL or file
*/
async extractExifData(source: string | File | Blob): Promise<ExifData | null> {
try {
// Check cache if URL
if (typeof source === 'string' && this.exifCache.has(source)) {
// Move to end for LRU
this.updateCacheOrder(source);
return this.exifCache.get(source)!;
}
// Get array buffer with size validation
const buffer = await this.getArrayBuffer(source);
// Validate buffer size
if (buffer.byteLength > this.MAX_BUFFER_SIZE) {
console.error('Buffer size exceeds maximum allowed size');
return null;
}
// Parse EXIF data
const exifData = this.parseExifData(buffer);
// Cache if URL with LRU eviction
if (typeof source === 'string' && exifData) {
this.addToCache(source, exifData);
}
return exifData;
} catch (error) {
console.error('Failed to extract EXIF data:', error);
return null;
}
}
/**
* Get array buffer from various sources
*/
private async getArrayBuffer(source: string | File | Blob): Promise<ArrayBuffer> {
if (source instanceof File || source instanceof Blob) {
return source.arrayBuffer();
} else {
const response = await fetch(source);
return response.arrayBuffer();
}
}
/**
* Parse EXIF data from array buffer
*/
private parseExifData(buffer: ArrayBuffer): ExifData | null {
const dataView = new DataView(buffer);
// Check for JPEG SOI marker
if (dataView.getUint16(0) !== 0xFFD8) {
return null; // Not a JPEG
}
// Find APP1 marker (EXIF)
let offset = 2;
let marker;
while (offset < dataView.byteLength) {
marker = dataView.getUint16(offset);
if (marker === 0xFFE1) {
// Found EXIF marker
return this.parseExifSegment(dataView, offset + 2);
}
if ((marker & 0xFF00) !== 0xFF00) {
break; // Invalid marker
}
offset += 2 + dataView.getUint16(offset + 2);
}
return null;
}
/**
* Parse EXIF segment with bounds checking
*/
private parseExifSegment(dataView: DataView, offset: number): ExifData | null {
// Bounds check
if (offset + 2 > dataView.byteLength) {
console.error('Invalid offset for EXIF segment');
return null;
}
const length = dataView.getUint16(offset);
// Validate segment length
if (offset + length > dataView.byteLength) {
console.error('EXIF segment length exceeds buffer size');
return null;
}
// Check for "Exif\0\0" identifier with bounds check
if (offset + 6 > dataView.byteLength) {
console.error('Invalid EXIF header offset');
return null;
}
const exifHeader = String.fromCharCode(
dataView.getUint8(offset + 2),
dataView.getUint8(offset + 3),
dataView.getUint8(offset + 4),
dataView.getUint8(offset + 5)
);
if (exifHeader !== 'Exif') {
return null;
}
// TIFF header offset
const tiffOffset = offset + 8;
// Check byte order
const byteOrder = dataView.getUint16(tiffOffset);
const littleEndian = byteOrder === 0x4949; // 'II' for Intel
if (byteOrder !== 0x4949 && byteOrder !== 0x4D4D) {
return null; // Invalid byte order
}
// Parse IFD
const ifdOffset = this.getUint32(dataView, tiffOffset + 4, littleEndian);
const exifData = this.parseIFD(dataView, tiffOffset, tiffOffset + ifdOffset, littleEndian);
// Parse GPS data if available
if (exifData.raw?.gpsIFDPointer) {
const gpsData = this.parseGPSIFD(
dataView,
tiffOffset,
tiffOffset + exifData.raw.gpsIFDPointer,
littleEndian
);
Object.assign(exifData, gpsData);
}
return this.formatExifData(exifData);
}
/**
* Parse IFD (Image File Directory) with bounds checking
*/
private parseIFD(
dataView: DataView,
tiffOffset: number,
ifdOffset: number,
littleEndian: boolean
): ExifData {
// Bounds check for IFD offset
if (ifdOffset + 2 > dataView.byteLength) {
console.error('Invalid IFD offset');
return { raw: {} };
}
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
// Validate number of entries
if (numEntries > 1000) { // Reasonable limit
console.error('Too many IFD entries');
return { raw: {} };
}
const data: ExifData = { raw: {} };
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + (i * 12);
// Bounds check for entry
if (entryOffset + 12 > dataView.byteLength) {
console.warn('IFD entry exceeds buffer bounds');
break;
}
const tag = this.getUint16(dataView, entryOffset, littleEndian);
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
const value = this.getTagValue(
dataView,
tiffOffset,
type,
count,
valueOffset,
littleEndian
);
const tagName = EXIF_TAGS[tag];
if (tagName) {
(data as any)[tagName] = value;
}
// Store raw value
data.raw![tag] = value;
// Check for EXIF IFD pointer
if (tag === 0x8769) {
const exifIFDOffset = tiffOffset + value;
const exifData = this.parseIFD(dataView, tiffOffset, exifIFDOffset, littleEndian);
Object.assign(data, exifData);
}
// Store GPS IFD pointer
if (tag === 0x8825) {
data.raw!.gpsIFDPointer = value;
}
}
return data;
}
/**
* Parse GPS IFD
*/
private parseGPSIFD(
dataView: DataView,
tiffOffset: number,
ifdOffset: number,
littleEndian: boolean
): Partial<ExifData> {
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
const gpsData: any = {};
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + (i * 12);
// Bounds check for entry
if (entryOffset + 12 > dataView.byteLength) {
console.warn('IFD entry exceeds buffer bounds');
break;
}
const tag = this.getUint16(dataView, entryOffset, littleEndian);
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
const value = this.getTagValue(
dataView,
tiffOffset,
type,
count,
valueOffset,
littleEndian
);
const tagName = GPS_TAGS[tag];
if (tagName) {
gpsData[tagName] = value;
}
}
// Convert GPS coordinates
const result: Partial<ExifData> = {};
if (gpsData.gpsLatitude && gpsData.gpsLatitudeRef) {
result.gpsLatitude = this.convertGPSCoordinate(
gpsData.gpsLatitude,
gpsData.gpsLatitudeRef
);
}
if (gpsData.gpsLongitude && gpsData.gpsLongitudeRef) {
result.gpsLongitude = this.convertGPSCoordinate(
gpsData.gpsLongitude,
gpsData.gpsLongitudeRef
);
}
if (gpsData.gpsAltitude) {
result.gpsAltitude = gpsData.gpsAltitude;
}
return result;
}
/**
* Get tag value based on type
*/
private getTagValue(
dataView: DataView,
tiffOffset: number,
type: number,
count: number,
offset: number,
littleEndian: boolean
): any {
switch (type) {
case 1: // BYTE
case 7: // UNDEFINED
if (count === 1) {
return dataView.getUint8(offset);
}
const bytes = [];
for (let i = 0; i < count; i++) {
bytes.push(dataView.getUint8(offset + i));
}
return bytes;
case 2: // ASCII
const stringOffset = count > 4
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
: offset;
let str = '';
for (let i = 0; i < count - 1; i++) {
const char = dataView.getUint8(stringOffset + i);
if (char === 0) break;
str += String.fromCharCode(char);
}
return str;
case 3: // SHORT
if (count === 1) {
return this.getUint16(dataView, offset, littleEndian);
}
const shorts = [];
const shortOffset = count > 2
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
: offset;
for (let i = 0; i < count; i++) {
shorts.push(this.getUint16(dataView, shortOffset + i * 2, littleEndian));
}
return shorts;
case 4: // LONG
if (count === 1) {
return this.getUint32(dataView, offset, littleEndian);
}
const longs = [];
const longOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
for (let i = 0; i < count; i++) {
longs.push(this.getUint32(dataView, longOffset + i * 4, littleEndian));
}
return longs;
case 5: // RATIONAL
const ratOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
if (count === 1) {
const num = this.getUint32(dataView, ratOffset, littleEndian);
const den = this.getUint32(dataView, ratOffset + 4, littleEndian);
return den === 0 ? 0 : num / den;
}
const rationals = [];
for (let i = 0; i < count; i++) {
const num = this.getUint32(dataView, ratOffset + i * 8, littleEndian);
const den = this.getUint32(dataView, ratOffset + i * 8 + 4, littleEndian);
rationals.push(den === 0 ? 0 : num / den);
}
return rationals;
default:
return null;
}
}
/**
* Convert GPS coordinate to decimal degrees
*/
private convertGPSCoordinate(coord: number[], ref: string): number {
if (!coord || coord.length !== 3) return 0;
const degrees = coord[0];
const minutes = coord[1];
const seconds = coord[2];
let decimal = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
decimal = -decimal;
}
return decimal;
}
/**
* Format EXIF data for display
*/
private formatExifData(data: ExifData): ExifData {
const formatted: ExifData = { ...data };
// Format dates
if (formatted.dateTime) {
formatted.dateTime = this.parseExifDate(formatted.dateTime as any);
}
if (formatted.dateTimeOriginal) {
formatted.dateTimeOriginal = this.parseExifDate(formatted.dateTimeOriginal as any);
}
if (formatted.dateTimeDigitized) {
formatted.dateTimeDigitized = this.parseExifDate(formatted.dateTimeDigitized as any);
}
// Format exposure time
if (formatted.exposureTime) {
const time = formatted.exposureTime as any;
if (typeof time === 'number') {
if (time < 1) {
formatted.exposureTime = `1/${Math.round(1 / time)}`;
} else {
formatted.exposureTime = `${time}s`;
}
}
}
// Format exposure program
if (formatted.exposureProgram) {
const programs = [
'Not defined',
'Manual',
'Normal program',
'Aperture priority',
'Shutter priority',
'Creative program',
'Action program',
'Portrait mode',
'Landscape mode'
];
const index = formatted.exposureProgram as any;
formatted.exposureProgram = programs[index] || 'Unknown';
}
// Format metering mode
if (formatted.meteringMode) {
const modes = [
'Unknown',
'Average',
'Center-weighted average',
'Spot',
'Multi-spot',
'Pattern',
'Partial'
];
const index = formatted.meteringMode as any;
formatted.meteringMode = modes[index] || 'Unknown';
}
// Format flash
if (formatted.flash !== undefined) {
const flash = formatted.flash as any;
formatted.flash = (flash & 1) ? 'Flash fired' : 'Flash did not fire';
}
return formatted;
}
/**
* Parse EXIF date string
*/
private parseExifDate(dateStr: string): Date {
// EXIF date format: "YYYY:MM:DD HH:MM:SS"
const parts = dateStr.split(' ');
if (parts.length !== 2) return new Date(dateStr);
const dateParts = parts[0].split(':');
const timeParts = parts[1].split(':');
if (dateParts.length !== 3 || timeParts.length !== 3) {
return new Date(dateStr);
}
return new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2]),
parseInt(timeParts[0]),
parseInt(timeParts[1]),
parseInt(timeParts[2])
);
}
/**
* Get uint16 with endianness and bounds checking
*/
private getUint16(dataView: DataView, offset: number, littleEndian: boolean): number {
if (offset + 2 > dataView.byteLength) {
console.error('Uint16 read exceeds buffer bounds');
return 0;
}
return dataView.getUint16(offset, littleEndian);
}
/**
* Get uint32 with endianness and bounds checking
*/
private getUint32(dataView: DataView, offset: number, littleEndian: boolean): number {
if (offset + 4 > dataView.byteLength) {
console.error('Uint32 read exceeds buffer bounds');
return 0;
}
return dataView.getUint32(offset, littleEndian);
}
/**
* Create EXIF display panel
*/
createExifPanel(exifData: ExifData): HTMLElement {
const panel = document.createElement('div');
panel.className = 'exif-panel';
panel.style.cssText = `
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 15px;
border-radius: 8px;
max-width: 400px;
max-height: 500px;
overflow-y: auto;
font-size: 12px;
`;
const sections = [
{
title: 'Camera',
fields: ['make', 'model', 'lensModel']
},
{
title: 'Settings',
fields: ['exposureTime', 'fNumber', 'iso', 'focalLength', 'exposureProgram', 'meteringMode', 'flash']
},
{
title: 'Image',
fields: ['imageWidth', 'imageHeight', 'orientation', 'colorSpace', 'whiteBalance']
},
{
title: 'Date/Time',
fields: ['dateTimeOriginal', 'dateTime']
},
{
title: 'Location',
fields: ['gpsLatitude', 'gpsLongitude', 'gpsAltitude']
},
{
title: 'Other',
fields: ['software', 'artist', 'copyright', 'imageDescription']
}
];
sections.forEach(section => {
const hasData = section.fields.some(field => (exifData as any)[field]);
if (!hasData) return;
const sectionDiv = document.createElement('div');
sectionDiv.style.marginBottom = '15px';
const title = document.createElement('h4');
// Use textContent for safe title insertion
title.textContent = section.title;
title.style.cssText = 'margin: 0 0 8px 0; color: #4CAF50;';
title.setAttribute('aria-label', `Section: ${section.title}`);
sectionDiv.appendChild(title);
section.fields.forEach(field => {
const value = (exifData as any)[field];
if (!value) return;
const row = document.createElement('div');
row.style.cssText = 'display: flex; justify-content: space-between; margin: 4px 0;';
const label = document.createElement('span');
// Use textContent for safe text insertion
label.textContent = this.formatFieldName(field) + ':';
label.style.color = '#aaa';
const val = document.createElement('span');
// Use textContent for safe value insertion
val.textContent = this.formatFieldValue(field, value);
val.style.textAlign = 'right';
row.appendChild(label);
row.appendChild(val);
sectionDiv.appendChild(row);
});
panel.appendChild(sectionDiv);
});
// Add GPS map link if coordinates available
if (exifData.gpsLatitude && exifData.gpsLongitude) {
const mapLink = document.createElement('a');
mapLink.href = `https://www.google.com/maps?q=${exifData.gpsLatitude},${exifData.gpsLongitude}`;
mapLink.target = '_blank';
mapLink.textContent = 'View on Map';
mapLink.style.cssText = `
display: inline-block;
margin-top: 10px;
padding: 8px 12px;
background: #4CAF50;
color: white;
text-decoration: none;
border-radius: 4px;
`;
panel.appendChild(mapLink);
}
return panel;
}
/**
* Format field name for display
*/
private formatFieldName(field: string): string {
const names: Record<string, string> = {
make: 'Camera Make',
model: 'Camera Model',
lensModel: 'Lens',
exposureTime: 'Shutter Speed',
fNumber: 'Aperture',
iso: 'ISO',
focalLength: 'Focal Length',
exposureProgram: 'Mode',
meteringMode: 'Metering',
flash: 'Flash',
imageWidth: 'Width',
imageHeight: 'Height',
orientation: 'Orientation',
colorSpace: 'Color Space',
whiteBalance: 'White Balance',
dateTimeOriginal: 'Date Taken',
dateTime: 'Date Modified',
gpsLatitude: 'Latitude',
gpsLongitude: 'Longitude',
gpsAltitude: 'Altitude',
software: 'Software',
artist: 'Artist',
copyright: 'Copyright',
imageDescription: 'Description'
};
return names[field] || field;
}
/**
* Format field value for display
*/
private formatFieldValue(field: string, value: any): string {
if (value instanceof Date) {
return value.toLocaleString();
}
switch (field) {
case 'fNumber':
return `f/${value}`;
case 'focalLength':
return `${value}mm`;
case 'gpsLatitude':
case 'gpsLongitude':
return value.toFixed(6) + '°';
case 'gpsAltitude':
return `${value.toFixed(1)}m`;
case 'imageWidth':
case 'imageHeight':
return `${value}px`;
default:
return String(value);
}
}
/**
* Add to cache with LRU eviction
*/
private addToCache(key: string, data: ExifData): void {
// Remove from order if exists
const existingIndex = this.cacheOrder.indexOf(key);
if (existingIndex !== -1) {
this.cacheOrder.splice(existingIndex, 1);
}
// Add to end
this.cacheOrder.push(key);
this.exifCache.set(key, data);
// Evict oldest if over limit
while (this.cacheOrder.length > this.MAX_CACHE_SIZE) {
const oldestKey = this.cacheOrder.shift();
if (oldestKey) {
this.exifCache.delete(oldestKey);
}
}
}
/**
* Update cache order for LRU
*/
private updateCacheOrder(key: string): void {
const index = this.cacheOrder.indexOf(key);
if (index !== -1) {
this.cacheOrder.splice(index, 1);
this.cacheOrder.push(key);
}
}
/**
* Clear EXIF cache
*/
clearCache(): void {
this.exifCache.clear();
this.cacheOrder = [];
}
}
export default ImageExifService.getInstance();

View File

@@ -1,681 +0,0 @@
/**
* Image Sharing and Export Module for Trilium Notes
* Provides functionality for sharing, downloading, and exporting images
*/
import server from './server.js';
import utils from './utils.js';
import toastService from './toast.js';
import type FNote from '../entities/fnote.js';
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Export format options
*/
export type ExportFormat = 'original' | 'jpeg' | 'png' | 'webp';
/**
* Export size presets
*/
export type SizePreset = 'original' | 'thumbnail' | 'small' | 'medium' | 'large' | 'custom';
/**
* Export configuration
*/
export interface ExportConfig {
format: ExportFormat;
quality: number; // 0-100 for JPEG/WebP
size: SizePreset;
customWidth?: number;
customHeight?: number;
maintainAspectRatio: boolean;
addWatermark: boolean;
watermarkText?: string;
watermarkPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
watermarkOpacity?: number;
}
/**
* Share options
*/
export interface ShareOptions {
method: 'link' | 'email' | 'social';
expiresIn?: number; // Hours
password?: string;
allowDownload: boolean;
trackViews: boolean;
}
/**
* Share link data
*/
export interface ShareLink {
url: string;
shortUrl?: string;
expiresAt?: Date;
password?: string;
views: number;
maxViews?: number;
created: Date;
}
/**
* Size presets in pixels
*/
const SIZE_PRESETS = {
thumbnail: { width: 150, height: 150 },
small: { width: 400, height: 400 },
medium: { width: 800, height: 800 },
large: { width: 1600, height: 1600 }
};
/**
* ImageSharingService handles image sharing, downloading, and exporting
*/
class ImageSharingService {
private static instance: ImageSharingService;
private activeShares: Map<string, ShareLink> = new Map();
private downloadCanvas?: HTMLCanvasElement;
private downloadContext?: CanvasRenderingContext2D;
// Canvas size limits for security and memory management
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
private defaultExportConfig: ExportConfig = {
format: 'original',
quality: 90,
size: 'original',
maintainAspectRatio: true,
addWatermark: false,
watermarkPosition: 'bottom-right',
watermarkOpacity: 0.5
};
private constructor() {
// Initialize download canvas
this.downloadCanvas = document.createElement('canvas');
this.downloadContext = this.downloadCanvas.getContext('2d') || undefined;
}
static getInstance(): ImageSharingService {
if (!ImageSharingService.instance) {
ImageSharingService.instance = new ImageSharingService();
}
return ImageSharingService.instance;
}
/**
* Download image with options
*/
async downloadImage(
src: string,
filename: string,
config?: Partial<ExportConfig>
): Promise<void> {
await withErrorBoundary(async () => {
// Validate inputs
ImageValidator.validateUrl(src);
const sanitizedFilename = ImageValidator.sanitizeFilename(filename);
const finalConfig = { ...this.defaultExportConfig, ...config };
// Load image
const img = await this.loadImage(src);
// Process image based on config
const processedBlob = await this.processImage(img, finalConfig);
// Create download link
const url = URL.createObjectURL(processedBlob);
const link = document.createElement('a');
link.href = url;
// Determine filename with extension
const extension = finalConfig.format === 'original'
? this.getOriginalExtension(sanitizedFilename)
: finalConfig.format;
const finalFilename = this.ensureExtension(sanitizedFilename, extension);
link.download = finalFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Cleanup
URL.revokeObjectURL(url);
toastService.showMessage(`Downloaded ${finalFilename}`);
});
}
/**
* Process image according to export configuration
*/
private async processImage(img: HTMLImageElement, config: ExportConfig): Promise<Blob> {
if (!this.downloadCanvas || !this.downloadContext) {
throw new Error('Canvas not initialized');
}
// Calculate dimensions
const { width, height } = this.calculateDimensions(
img.naturalWidth,
img.naturalHeight,
config
);
// Validate canvas dimensions
ImageValidator.validateDimensions(width, height);
// Check memory availability
const estimatedMemory = MemoryMonitor.estimateImageMemory(width, height);
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
throw new ImageError(
ImageErrorType.MEMORY_ERROR,
'Insufficient memory to process image',
{ width, height, estimatedMemory }
);
}
// Set canvas size
this.downloadCanvas.width = width;
this.downloadCanvas.height = height;
// Clear canvas
this.downloadContext.fillStyle = 'white';
this.downloadContext.fillRect(0, 0, width, height);
// Draw image
this.downloadContext.drawImage(img, 0, 0, width, height);
// Add watermark if enabled
if (config.addWatermark && config.watermarkText) {
this.addWatermark(this.downloadContext, width, height, config);
}
// Convert to blob
return new Promise((resolve, reject) => {
const mimeType = this.getMimeType(config.format);
const quality = config.quality / 100;
this.downloadCanvas!.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob'));
}
},
mimeType,
quality
);
});
}
/**
* Calculate dimensions based on size preset
*/
private calculateDimensions(
originalWidth: number,
originalHeight: number,
config: ExportConfig
): { width: number; height: number } {
if (config.size === 'original') {
return { width: originalWidth, height: originalHeight };
}
if (config.size === 'custom' && config.customWidth && config.customHeight) {
if (config.maintainAspectRatio) {
const aspectRatio = originalWidth / originalHeight;
const targetRatio = config.customWidth / config.customHeight;
if (aspectRatio > targetRatio) {
return {
width: config.customWidth,
height: Math.round(config.customWidth / aspectRatio)
};
} else {
return {
width: Math.round(config.customHeight * aspectRatio),
height: config.customHeight
};
}
}
return { width: config.customWidth, height: config.customHeight };
}
// Use preset
const preset = SIZE_PRESETS[config.size as keyof typeof SIZE_PRESETS];
if (!preset) {
return { width: originalWidth, height: originalHeight };
}
if (config.maintainAspectRatio) {
const aspectRatio = originalWidth / originalHeight;
const maxWidth = preset.width;
const maxHeight = preset.height;
let width = originalWidth;
let height = originalHeight;
if (width > maxWidth) {
width = maxWidth;
height = Math.round(width / aspectRatio);
}
if (height > maxHeight) {
height = maxHeight;
width = Math.round(height * aspectRatio);
}
return { width, height };
}
return preset;
}
/**
* Add watermark to canvas
*/
private addWatermark(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
config: ExportConfig
): void {
if (!config.watermarkText) return;
ctx.save();
// Set watermark style
ctx.globalAlpha = config.watermarkOpacity || 0.5;
ctx.fillStyle = 'white';
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.font = `${Math.min(width, height) * 0.05}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Calculate position
let x = width / 2;
let y = height / 2;
switch (config.watermarkPosition) {
case 'top-left':
x = width * 0.1;
y = height * 0.1;
ctx.textAlign = 'left';
break;
case 'top-right':
x = width * 0.9;
y = height * 0.1;
ctx.textAlign = 'right';
break;
case 'bottom-left':
x = width * 0.1;
y = height * 0.9;
ctx.textAlign = 'left';
break;
case 'bottom-right':
x = width * 0.9;
y = height * 0.9;
ctx.textAlign = 'right';
break;
}
// Draw watermark with outline
ctx.strokeText(config.watermarkText, x, y);
ctx.fillText(config.watermarkText, x, y);
ctx.restore();
}
/**
* Generate shareable link for image
*/
async generateShareLink(
noteId: string,
options?: Partial<ShareOptions>
): Promise<ShareLink> {
try {
const finalOptions = {
method: 'link' as const,
allowDownload: true,
trackViews: false,
...options
};
// Create share token on server
const response = await server.post(`notes/${noteId}/share`, {
type: 'image',
expiresIn: finalOptions.expiresIn,
password: finalOptions.password,
allowDownload: finalOptions.allowDownload,
trackViews: finalOptions.trackViews
});
const shareLink: ShareLink = {
url: `${window.location.origin}/share/${response.token}`,
shortUrl: response.shortUrl,
expiresAt: response.expiresAt ? new Date(response.expiresAt) : undefined,
password: finalOptions.password,
views: 0,
maxViews: response.maxViews,
created: new Date()
};
// Store in active shares
this.activeShares.set(response.token, shareLink);
return shareLink;
} catch (error) {
console.error('Failed to generate share link:', error);
throw error;
}
}
/**
* Copy image or link to clipboard
*/
async copyToClipboard(
src: string,
type: 'image' | 'link' = 'link'
): Promise<void> {
await withErrorBoundary(async () => {
// Validate URL
ImageValidator.validateUrl(src);
if (type === 'link') {
// Copy URL to clipboard
await navigator.clipboard.writeText(src);
toastService.showMessage('Link copied to clipboard');
} else {
// Copy image data to clipboard
const img = await this.loadImage(src);
if (!this.downloadCanvas || !this.downloadContext) {
throw new Error('Canvas not initialized');
}
// Validate dimensions before setting
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
this.downloadCanvas.width = img.naturalWidth;
this.downloadCanvas.height = img.naturalHeight;
this.downloadContext.drawImage(img, 0, 0);
this.downloadCanvas.toBlob(async (blob) => {
if (blob) {
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
toastService.showMessage('Image copied to clipboard');
} catch (error) {
console.error('Failed to copy image to clipboard:', error);
// Fallback to copying link
await navigator.clipboard.writeText(src);
toastService.showMessage('Image link copied to clipboard');
}
}
});
}
});
}
/**
* Share via native share API (mobile)
*/
async shareNative(
src: string,
title: string,
text?: string
): Promise<void> {
if (!navigator.share) {
throw new Error('Native share not supported');
}
try {
// Try to share with file
const img = await this.loadImage(src);
const blob = await this.processImage(img, this.defaultExportConfig);
const file = new File([blob], `${title}.${this.defaultExportConfig.format}`, {
type: this.getMimeType(this.defaultExportConfig.format)
});
await navigator.share({
title,
text: text || `Check out this image: ${title}`,
files: [file]
});
} catch (error) {
// Fallback to sharing URL
try {
await navigator.share({
title,
text: text || `Check out this image: ${title}`,
url: src
});
} catch (shareError) {
console.error('Failed to share:', shareError);
throw shareError;
}
}
}
/**
* Export multiple images as ZIP
*/
async exportBatch(
images: Array<{ src: string; filename: string }>,
config?: Partial<ExportConfig>
): Promise<void> {
try {
// Dynamic import of JSZip
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const finalConfig = { ...this.defaultExportConfig, ...config };
// Process each image
for (const { src, filename } of images) {
try {
const img = await this.loadImage(src);
const blob = await this.processImage(img, finalConfig);
const extension = finalConfig.format === 'original'
? this.getOriginalExtension(filename)
: finalConfig.format;
const finalFilename = this.ensureExtension(filename, extension);
zip.file(finalFilename, blob);
} catch (error) {
console.error(`Failed to process image ${filename}:`, error);
}
}
// Generate and download ZIP
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = url;
link.download = `images_${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toastService.showMessage(`Exported ${images.length} images`);
} catch (error) {
console.error('Failed to export images:', error);
toastService.showError('Failed to export images');
throw error;
}
}
/**
* Open share dialog
*/
openShareDialog(
src: string,
title: string,
noteId?: string
): void {
// Create modal dialog
const dialog = document.createElement('div');
dialog.className = 'share-dialog-overlay';
dialog.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const content = document.createElement('div');
content.className = 'share-dialog';
content.style.cssText = `
background: white;
border-radius: 8px;
padding: 20px;
width: 400px;
max-width: 90%;
`;
content.innerHTML = `
<h3 style="margin: 0 0 15px 0;">Share Image</h3>
<div class="share-options" style="display: flex; flex-direction: column; gap: 10px;">
<button class="share-copy-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-link"></i> Copy Link
</button>
<button class="share-copy-image" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-copy"></i> Copy Image
</button>
<button class="share-download" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-download"></i> Download
</button>
${navigator.share ? `
<button class="share-native" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-share"></i> Share...
</button>
` : ''}
${noteId ? `
<button class="share-generate-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-link-external"></i> Generate Share Link
</button>
` : ''}
</div>
<button class="close-dialog" style="margin-top: 15px; padding: 8px 16px; background: #f0f0f0; border: none; border-radius: 4px; cursor: pointer;">
Close
</button>
`;
// Add event handlers
content.querySelector('.share-copy-link')?.addEventListener('click', () => {
this.copyToClipboard(src, 'link');
dialog.remove();
});
content.querySelector('.share-copy-image')?.addEventListener('click', () => {
this.copyToClipboard(src, 'image');
dialog.remove();
});
content.querySelector('.share-download')?.addEventListener('click', () => {
this.downloadImage(src, title);
dialog.remove();
});
content.querySelector('.share-native')?.addEventListener('click', () => {
this.shareNative(src, title);
dialog.remove();
});
content.querySelector('.share-generate-link')?.addEventListener('click', async () => {
if (noteId) {
const link = await this.generateShareLink(noteId);
await this.copyToClipboard(link.url, 'link');
dialog.remove();
}
});
content.querySelector('.close-dialog')?.addEventListener('click', () => {
dialog.remove();
});
dialog.appendChild(content);
document.body.appendChild(dialog);
}
/**
* Load image from URL
*/
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/**
* Get MIME type for format
*/
private getMimeType(format: ExportFormat): string {
switch (format) {
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
}
/**
* Get original extension from filename
*/
private getOriginalExtension(filename: string): string {
const parts = filename.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase();
}
return 'png';
}
/**
* Ensure filename has correct extension
*/
private ensureExtension(filename: string, extension: string): string {
const parts = filename.split('.');
if (parts.length > 1) {
parts[parts.length - 1] = extension;
return parts.join('.');
}
return `${filename}.${extension}`;
}
/**
* Cleanup resources
*/
cleanup(): void {
this.activeShares.clear();
// Clean up canvas memory
if (this.downloadCanvas && this.downloadContext) {
this.downloadContext.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height);
this.downloadCanvas.width = 0;
this.downloadCanvas.height = 0;
}
this.downloadCanvas = undefined;
this.downloadContext = undefined;
}
}
export default ImageSharingService.getInstance();

View File

@@ -1,552 +0,0 @@
import PhotoSwipe from 'photoswipe';
import type PhotoSwipeOptions from 'photoswipe';
import type { DataSource, SlideData } from 'photoswipe';
import 'photoswipe/style.css';
import '../styles/photoswipe-mobile-a11y.css';
import mobileA11yService, { type MobileA11yConfig } from './photoswipe_mobile_a11y.js';
// Define Content type locally since it's not exported by PhotoSwipe
interface Content {
width?: number;
height?: number;
[key: string]: any;
}
// Define AugmentedEvent type locally
interface AugmentedEvent<T extends string> {
content: Content;
slide?: any;
preventDefault?: () => void;
[key: string]: any;
}
/**
* Media item interface for PhotoSwipe gallery
*/
export interface MediaItem {
src: string;
width?: number;
height?: number;
alt?: string;
title?: string;
noteId?: string;
element?: HTMLElement;
msrc?: string; // Thumbnail source
}
/**
* Configuration options for the media viewer
*/
export interface MediaViewerConfig {
bgOpacity?: number;
showHideOpacity?: boolean;
showAnimationDuration?: number;
hideAnimationDuration?: number;
allowPanToNext?: boolean;
spacing?: number;
maxSpreadZoom?: number;
getThumbBoundsFn?: (index: number) => { x: number; y: number; w: number } | undefined;
pinchToClose?: boolean;
closeOnScroll?: boolean;
closeOnVerticalDrag?: boolean;
mouseMovePan?: boolean;
arrowKeys?: boolean;
returnFocus?: boolean;
escKey?: boolean;
errorMsg?: string;
preloadFirstSlide?: boolean;
preload?: [number, number];
loop?: boolean;
wheelToZoom?: boolean;
mobileA11y?: MobileA11yConfig; // Mobile and accessibility configuration
}
/**
* Event callbacks for media viewer
*/
export interface MediaViewerCallbacks {
onOpen?: () => void;
onClose?: () => void;
onChange?: (index: number) => void;
onImageLoad?: (index: number, item: MediaItem) => void;
onImageError?: (index: number, item: MediaItem, error?: Error) => void;
}
/**
* PhotoSwipe data item with original item reference
*/
interface PhotoSwipeDataItem extends SlideData {
_originalItem?: MediaItem;
}
/**
* Error handler for media viewer operations
*/
class MediaViewerError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = 'MediaViewerError';
}
}
/**
* MediaViewerService manages the PhotoSwipe lightbox for viewing images and media
* in Trilium Notes. Implements singleton pattern for global access.
*/
class MediaViewerService {
private static instance: MediaViewerService;
private photoSwipe: PhotoSwipe | null = null;
private defaultConfig: MediaViewerConfig;
private currentItems: MediaItem[] = [];
private callbacks: MediaViewerCallbacks = {};
private cleanupHandlers: Array<() => void> = [];
private constructor() {
// Default configuration optimized for Trilium
this.defaultConfig = {
bgOpacity: 0.95,
showHideOpacity: true,
showAnimationDuration: 250,
hideAnimationDuration: 250,
allowPanToNext: true,
spacing: 0.12,
maxSpreadZoom: 4,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
mouseMovePan: true,
arrowKeys: true,
returnFocus: true,
escKey: true,
errorMsg: 'The image could not be loaded',
preloadFirstSlide: true,
preload: [1, 2],
loop: true,
wheelToZoom: true
};
// Setup global cleanup on window unload
window.addEventListener('beforeunload', () => this.destroy());
}
/**
* Get singleton instance of MediaViewerService
*/
static getInstance(): MediaViewerService {
if (!MediaViewerService.instance) {
MediaViewerService.instance = new MediaViewerService();
}
return MediaViewerService.instance;
}
/**
* Open the media viewer with specified items
*/
open(items: MediaItem[], startIndex: number = 0, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
try {
// Validate inputs
if (!items || items.length === 0) {
throw new MediaViewerError('No items provided to media viewer');
}
if (startIndex < 0 || startIndex >= items.length) {
console.warn(`Invalid start index ${startIndex}, using 0`);
startIndex = 0;
}
// Close any existing viewer
this.close();
this.currentItems = items;
this.callbacks = callbacks || {};
// Prepare data source for PhotoSwipe with error handling
const dataSource: DataSource = items.map((item, index) => {
try {
return this.prepareItem(item);
} catch (error) {
console.error(`Failed to prepare item at index ${index}:`, error);
// Return a minimal valid item as fallback
return {
src: item.src,
width: 800,
height: 600,
alt: item.alt || 'Error loading image'
} as PhotoSwipeDataItem;
}
});
// Merge configurations
const finalConfig = {
...this.defaultConfig,
...config,
dataSource,
index: startIndex,
errorMsg: config?.errorMsg || 'The image could not be loaded. Please try again.'
};
// Create and initialize PhotoSwipe
this.photoSwipe = new PhotoSwipe(finalConfig);
// Setup event handlers
this.setupEventHandlers();
// Apply mobile and accessibility enhancements
if (config?.mobileA11y || this.shouldAutoEnhance()) {
mobileA11yService.enhancePhotoSwipe(this.photoSwipe, config?.mobileA11y);
}
// Initialize the viewer
this.photoSwipe.init();
} catch (error) {
console.error('Failed to open media viewer:', error);
// Cleanup on error
this.close();
// Re-throw as MediaViewerError
throw error instanceof MediaViewerError ? error : new MediaViewerError('Failed to open media viewer', error);
}
}
/**
* Open a single image in the viewer
*/
openSingle(item: MediaItem, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
this.open([item], 0, config, callbacks);
}
/**
* Close the media viewer
*/
close(): void {
if (this.photoSwipe) {
this.photoSwipe.destroy();
this.photoSwipe = null;
this.cleanupEventHandlers();
}
}
/**
* Navigate to next item
*/
next(): void {
if (this.photoSwipe) {
this.photoSwipe.next();
}
}
/**
* Navigate to previous item
*/
prev(): void {
if (this.photoSwipe) {
this.photoSwipe.prev();
}
}
/**
* Go to specific slide by index
*/
goTo(index: number): void {
if (this.photoSwipe && index >= 0 && index < this.currentItems.length) {
this.photoSwipe.goTo(index);
}
}
/**
* Get current slide index
*/
getCurrentIndex(): number {
return this.photoSwipe ? this.photoSwipe.currIndex : -1;
}
/**
* Check if viewer is open
*/
isOpen(): boolean {
return this.photoSwipe !== null;
}
/**
* Update configuration dynamically
*/
updateConfig(config: Partial<MediaViewerConfig>): void {
this.defaultConfig = {
...this.defaultConfig,
...config
};
}
/**
* Prepare item for PhotoSwipe
*/
private prepareItem(item: MediaItem): PhotoSwipeDataItem {
const prepared: PhotoSwipeDataItem = {
src: item.src,
alt: item.alt || '',
title: item.title
};
// If dimensions are provided, use them
if (item.width && item.height) {
prepared.width = item.width;
prepared.height = item.height;
} else {
// Default dimensions - will be updated when image loads
prepared.width = 0;
prepared.height = 0;
}
// Add thumbnail if provided
if (item.msrc) {
prepared.msrc = item.msrc;
}
// Store original item reference
prepared._originalItem = item;
return prepared;
}
/**
* Setup event handlers for PhotoSwipe
*/
private setupEventHandlers(): void {
if (!this.photoSwipe) return;
// Opening event
const openHandler = () => {
if (this.callbacks.onOpen) {
this.callbacks.onOpen();
}
};
this.photoSwipe.on('openingAnimationEnd', openHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('openingAnimationEnd', openHandler));
// Closing event
const closeHandler = () => {
if (this.callbacks.onClose) {
this.callbacks.onClose();
}
};
this.photoSwipe.on('close', closeHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('close', closeHandler));
// Change event
const changeHandler = () => {
if (this.callbacks.onChange && this.photoSwipe) {
this.callbacks.onChange(this.photoSwipe.currIndex);
}
};
this.photoSwipe.on('change', changeHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('change', changeHandler));
// Image load event - also update dimensions if needed
const loadCompleteHandler = (e: any) => {
try {
const { content } = e;
const extContent = content as Content & { type?: string; data?: HTMLImageElement; index?: number; _originalItem?: MediaItem };
if (extContent.type === 'image' && extContent.data) {
// Update dimensions if they were not provided
if (content.width === 0 || content.height === 0) {
const img = extContent.data;
content.width = img.naturalWidth;
content.height = img.naturalHeight;
if (typeof extContent.index === 'number') {
this.photoSwipe?.refreshSlideContent(extContent.index);
}
}
if (this.callbacks.onImageLoad && typeof extContent.index === 'number' && extContent._originalItem) {
this.callbacks.onImageLoad(extContent.index, extContent._originalItem);
}
}
} catch (error) {
console.error('Error in loadComplete handler:', error);
}
};
this.photoSwipe.on('loadComplete', loadCompleteHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadComplete', loadCompleteHandler));
// Image error event
const errorHandler = (e: any) => {
try {
const { content } = e;
const extContent = content as Content & { index?: number; _originalItem?: MediaItem };
if (this.callbacks.onImageError && typeof extContent.index === 'number' && extContent._originalItem) {
const error = new MediaViewerError(`Failed to load image at index ${extContent.index}`);
this.callbacks.onImageError(extContent.index, extContent._originalItem, error);
}
} catch (error) {
console.error('Error in errorHandler:', error);
}
};
this.photoSwipe.on('loadError', errorHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadError', errorHandler));
}
/**
* Cleanup event handlers
*/
private cleanupEventHandlers(): void {
this.cleanupHandlers.forEach(handler => handler());
this.cleanupHandlers = [];
}
/**
* Destroy the service and cleanup resources
*/
destroy(): void {
this.close();
this.currentItems = [];
this.callbacks = {};
// Cleanup mobile and accessibility enhancements
mobileA11yService.cleanup();
}
/**
* Get dimensions from image element or URL with proper resource cleanup
*/
async getImageDimensions(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
let resolved = false;
const cleanup = () => {
img.onload = null;
img.onerror = null;
// Clear the src to help with garbage collection
if (!resolved) {
img.src = '';
}
};
img.onload = () => {
resolved = true;
const dimensions = {
width: img.naturalWidth,
height: img.naturalHeight
};
cleanup();
resolve(dimensions);
};
img.onerror = () => {
const error = new MediaViewerError(`Failed to load image: ${src}`);
cleanup();
reject(error);
};
// Set a timeout for image loading
const timeoutId = setTimeout(() => {
if (!resolved) {
cleanup();
reject(new MediaViewerError(`Image loading timeout: ${src}`));
}
}, 30000); // 30 second timeout
img.src = src;
// Clear timeout on success or error
// Store the original handlers with timeout cleanup
const originalOnload = img.onload;
const originalOnerror = img.onerror;
img.onload = function(ev: Event) {
clearTimeout(timeoutId);
if (originalOnload) {
originalOnload.call(img, ev);
}
};
img.onerror = function(ev: Event | string) {
clearTimeout(timeoutId);
if (originalOnerror) {
originalOnerror.call(img, ev);
}
};
});
}
/**
* Create items from image elements in a container with error isolation
*/
async createItemsFromContainer(container: HTMLElement, selector: string = 'img'): Promise<MediaItem[]> {
const images = container.querySelectorAll<HTMLImageElement>(selector);
const items: MediaItem[] = [];
// Process each image with isolated error handling
const promises = Array.from(images).map(async (img) => {
try {
const item: MediaItem = {
src: img.src,
alt: img.alt || `Image ${items.length + 1}`,
title: img.title || img.alt || `Image ${items.length + 1}`,
element: img,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Try to get dimensions if not available
if (!item.width || !item.height) {
try {
const dimensions = await this.getImageDimensions(img.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
// Log but don't fail - image will still be viewable
console.warn(`Failed to get dimensions for image: ${img.src}`, error);
// Set default dimensions as fallback
item.width = 800;
item.height = 600;
}
}
return item;
} catch (error) {
// Log error but continue processing other images
console.error(`Failed to process image: ${img.src}`, error);
return null;
}
});
// Wait for all promises and filter out nulls
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'fulfilled' && result.value !== null) {
items.push(result.value);
}
}
return items;
}
/**
* Apply theme-specific styles
*/
applyTheme(isDarkTheme: boolean): void {
// This will be expanded to modify PhotoSwipe's appearance based on Trilium's theme
const opacity = isDarkTheme ? 0.95 : 0.9;
this.updateConfig({ bgOpacity: opacity });
}
/**
* Check if mobile/accessibility enhancements should be auto-enabled
*/
private shouldAutoEnhance(): boolean {
// Auto-enable for touch devices
const isTouchDevice = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0;
// Auto-enable if user has accessibility preferences
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const prefersHighContrast = window.matchMedia('(prefers-contrast: high)').matches;
return isTouchDevice || prefersReducedMotion || prefersHighContrast;
}
}
// Export singleton instance
export default MediaViewerService.getInstance();

View File

@@ -1,541 +0,0 @@
/**
* Tests for PhotoSwipe Mobile & Accessibility Enhancement Module
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import type PhotoSwipe from 'photoswipe';
import mobileA11yService from './photoswipe_mobile_a11y.js';
// Mock PhotoSwipe
const mockPhotoSwipe = {
template: document.createElement('div'),
currSlide: {
currZoomLevel: 1,
zoomTo: jest.fn(),
data: {
src: 'test.jpg',
alt: 'Test image',
title: 'Test',
width: 800,
height: 600
}
},
currIndex: 0,
viewportSize: { x: 800, y: 600 },
ui: { toggle: jest.fn() },
next: jest.fn(),
prev: jest.fn(),
goTo: jest.fn(),
close: jest.fn(),
getNumItems: () => 5,
on: jest.fn(),
off: jest.fn(),
options: {
showAnimationDuration: 250,
hideAnimationDuration: 250
}
} as unknown as PhotoSwipe;
describe('PhotoSwipeMobileA11yService', () => {
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Reset mocks
jest.clearAllMocks();
});
afterEach(() => {
// Cleanup
mobileA11yService.cleanup();
});
describe('Device Capabilities Detection', () => {
it('should detect touch device capabilities', () => {
// Add touch support to window
Object.defineProperty(window, 'ontouchstart', {
value: () => {},
writable: true
});
// Service should detect touch support on initialization
const service = mobileA11yService;
expect(service).toBeDefined();
});
it('should detect accessibility preferences', () => {
// Mock matchMedia for reduced motion
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
const service = mobileA11yService;
expect(service).toBeDefined();
});
});
describe('ARIA Live Region', () => {
it('should create ARIA live region for announcements', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const liveRegion = document.querySelector('[aria-live]');
expect(liveRegion).toBeTruthy();
expect(liveRegion?.getAttribute('aria-live')).toBe('polite');
expect(liveRegion?.getAttribute('role')).toBe('status');
});
it('should announce changes to screen readers', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
a11y: {
enableScreenReaderAnnouncements: true
}
});
const liveRegion = document.querySelector('[aria-live]');
// Trigger navigation
const changeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
.find(call => call[0] === 'change')?.[1];
if (changeHandler) {
changeHandler();
// Check if announcement was made
expect(liveRegion?.textContent).toContain('Image 1 of 5');
}
});
});
describe('Keyboard Navigation', () => {
it('should handle arrow key navigation', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Simulate arrow key presses
const leftArrow = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
const rightArrow = new KeyboardEvent('keydown', { key: 'ArrowRight' });
document.dispatchEvent(leftArrow);
expect(mockPhotoSwipe.prev).toHaveBeenCalled();
document.dispatchEvent(rightArrow);
expect(mockPhotoSwipe.next).toHaveBeenCalled();
});
it('should handle zoom with arrow keys', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const upArrow = new KeyboardEvent('keydown', { key: 'ArrowUp' });
const downArrow = new KeyboardEvent('keydown', { key: 'ArrowDown' });
document.dispatchEvent(upArrow);
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Object),
333
);
document.dispatchEvent(downArrow);
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledTimes(2);
});
it('should show keyboard help on ? key', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const helpKey = new KeyboardEvent('keydown', { key: '?' });
document.dispatchEvent(helpKey);
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
expect(helpDialog).toBeTruthy();
expect(helpDialog?.getAttribute('role')).toBe('dialog');
});
it('should support quick navigation with number keys', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const key3 = new KeyboardEvent('keydown', { key: '3' });
document.dispatchEvent(key3);
expect(mockPhotoSwipe.goTo).toHaveBeenCalledWith(2); // 0-indexed
});
});
describe('Touch Gestures', () => {
it('should handle pinch to zoom', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Simulate pinch gesture
const touch1 = { clientX: 100, clientY: 100, identifier: 0 };
const touch2 = { clientX: 200, clientY: 200, identifier: 1 };
const touchStart = new TouchEvent('touchstart', {
touches: [touch1, touch2] as any
});
element?.dispatchEvent(touchStart);
// Move touches apart (zoom in)
const touch1Move = { clientX: 50, clientY: 50, identifier: 0 };
const touch2Move = { clientX: 250, clientY: 250, identifier: 1 };
const touchMove = new TouchEvent('touchmove', {
touches: [touch1Move, touch2Move] as any
});
element?.dispatchEvent(touchMove);
// Zoom should be triggered
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
});
it('should handle double tap to zoom', (done) => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
const pos = { clientX: 400, clientY: 300 };
// First tap
const firstTap = new TouchEvent('touchend', {
changedTouches: [{ ...pos, identifier: 0 }] as any
});
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ ...pos, identifier: 0 }] as any
}));
element?.dispatchEvent(firstTap);
// Second tap within double tap delay
setTimeout(() => {
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ ...pos, identifier: 0 }] as any
}));
const secondTap = new TouchEvent('touchend', {
changedTouches: [{ ...pos, identifier: 0 }] as any
});
element?.dispatchEvent(secondTap);
// Check zoom was triggered
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
done();
}, 100);
});
it('should detect swipe gestures', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Simulate swipe left
const touchStart = new TouchEvent('touchstart', {
touches: [{ clientX: 300, clientY: 300, identifier: 0 }] as any
});
const touchEnd = new TouchEvent('touchend', {
changedTouches: [{ clientX: 100, clientY: 300, identifier: 0 }] as any
});
element?.dispatchEvent(touchStart);
element?.dispatchEvent(touchEnd);
// Should navigate to next image
expect(mockPhotoSwipe.next).toHaveBeenCalled();
});
});
describe('Focus Management', () => {
it('should trap focus within gallery', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Add focusable elements
const button1 = document.createElement('button');
const button2 = document.createElement('button');
element?.appendChild(button1);
element?.appendChild(button2);
// Focus first button
button1.focus();
// Simulate Tab on last focusable element
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false
});
button2.focus();
element?.dispatchEvent(tabEvent);
// Focus should wrap to first element
expect(document.activeElement).toBe(button1);
});
it('should restore focus on close', () => {
const originalFocus = document.createElement('button');
document.body.appendChild(originalFocus);
originalFocus.focus();
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Trigger close handler
const closeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
.find(call => call[0] === 'close')?.[1];
if (closeHandler) {
closeHandler();
// Focus should be restored
expect(document.activeElement).toBe(originalFocus);
}
});
});
describe('ARIA Attributes', () => {
it('should add proper ARIA attributes to gallery', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
expect(element?.getAttribute('role')).toBe('dialog');
expect(element?.getAttribute('aria-label')).toContain('Image gallery');
expect(element?.getAttribute('aria-modal')).toBe('true');
});
it('should label controls for screen readers', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Add mock controls
const prevBtn = document.createElement('button');
prevBtn.className = 'pswp__button--arrow--prev';
element?.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.className = 'pswp__button--arrow--next';
element?.appendChild(nextBtn);
// Enhance again to label the newly added controls
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
expect(prevBtn.getAttribute('aria-label')).toBe('Previous image');
expect(nextBtn.getAttribute('aria-label')).toBe('Next image');
});
});
describe('Mobile UI Adaptations', () => {
it('should ensure minimum touch target size', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
mobileUI: {
minTouchTargetSize: 44
}
});
const element = mockPhotoSwipe.template;
// Add a button
const button = document.createElement('button');
button.className = 'pswp__button';
button.style.width = '30px';
button.style.height = '30px';
element?.appendChild(button);
// Enhance to apply minimum sizes
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Button should be resized to meet minimum
expect(button.style.minWidth).toBe('44px');
expect(button.style.minHeight).toBe('44px');
});
it('should add swipe indicators for mobile', () => {
// Mock as mobile device
Object.defineProperty(window, 'ontouchstart', {
value: () => {},
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
mobileUI: {
swipeIndicators: true
}
});
const indicators = document.querySelector('.photoswipe-swipe-indicators');
expect(indicators).toBeTruthy();
});
});
describe('Performance Optimizations', () => {
it('should adapt quality based on device capabilities', () => {
// Mock low memory device
Object.defineProperty(navigator, 'deviceMemory', {
value: 1,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
performance: {
adaptiveQuality: true
}
});
// Service should detect low memory and adjust settings
expect(mobileA11yService).toBeDefined();
});
it('should apply reduced motion preferences', () => {
// Mock reduced motion preference
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Animations should be disabled
expect(mockPhotoSwipe.options.showAnimationDuration).toBe(0);
expect(mockPhotoSwipe.options.hideAnimationDuration).toBe(0);
});
it('should optimize for battery saving', () => {
// Mock battery API
const mockBattery = {
charging: false,
level: 0.15,
addEventListener: jest.fn()
};
(navigator as any).getBattery = jest.fn().mockResolvedValue(mockBattery);
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
performance: {
batteryOptimization: true
}
});
// Battery optimization should be enabled
expect((navigator as any).getBattery).toHaveBeenCalled();
});
});
describe('High Contrast Mode', () => {
it('should apply high contrast styles when enabled', () => {
// Mock high contrast preference
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-contrast: high)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Should have high contrast styles
expect(element?.style.outline).toContain('2px solid white');
});
});
describe('Haptic Feedback', () => {
it('should trigger haptic feedback on supported devices', () => {
// Mock vibration API
const mockVibrate = jest.fn();
Object.defineProperty(navigator, 'vibrate', {
value: mockVibrate,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
touch: {
hapticFeedback: true
}
});
// Trigger a gesture that should cause haptic feedback
const element = mockPhotoSwipe.template;
// Double tap
const tap = new TouchEvent('touchend', {
changedTouches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
});
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
}));
element?.dispatchEvent(tap);
// Quick second tap
setTimeout(() => {
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
}));
element?.dispatchEvent(tap);
// Haptic feedback should be triggered
expect(mockVibrate).toHaveBeenCalled();
}, 50);
});
});
describe('Configuration Updates', () => {
it('should update configuration dynamically', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Update configuration
mobileA11yService.updateConfig({
a11y: {
ariaLiveRegion: 'assertive'
},
touch: {
hapticFeedback: false
}
});
const liveRegion = document.querySelector('[aria-live]');
expect(liveRegion?.getAttribute('aria-live')).toBe('assertive');
});
});
describe('Cleanup', () => {
it('should properly cleanup resources', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Create some elements
const liveRegion = document.querySelector('[aria-live]');
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
expect(liveRegion).toBeTruthy();
// Cleanup
mobileA11yService.cleanup();
// Elements should be removed
expect(document.querySelector('[aria-live]')).toBeFalsy();
expect(document.querySelector('.photoswipe-keyboard-help')).toBeFalsy();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +0,0 @@
/**
* Gallery styles for PhotoSwipe integration
* Provides styling for gallery UI elements
*/
/* Gallery thumbnail strip */
.gallery-thumbnail-strip {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.gallery-thumbnail-strip::-webkit-scrollbar {
height: 6px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-track {
background: transparent;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.gallery-thumbnail {
position: relative;
overflow: hidden;
}
.gallery-thumbnail.active::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
/* Gallery controls animations */
.gallery-slideshow-controls button {
transition: transform 0.2s, background 0.2s;
}
.gallery-slideshow-controls button:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 1) !important;
}
.gallery-slideshow-controls button:active {
transform: scale(0.95);
}
/* Slideshow progress indicator */
.slideshow-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.2);
z-index: 101;
}
.slideshow-progress-bar {
height: 100%;
background: rgba(255, 255, 255, 0.8);
width: 0;
transition: width linear;
}
.slideshow-progress.active .slideshow-progress-bar {
animation: slideshow-progress var(--slideshow-interval) linear;
}
@keyframes slideshow-progress {
from { width: 0; }
to { width: 100%; }
}
/* Gallery counter styling */
.gallery-counter {
font-family: var(--font-family-monospace);
letter-spacing: 0.05em;
user-select: none;
}
.gallery-counter .current-index {
font-weight: bold;
color: #fff;
}
.gallery-counter .total-count {
opacity: 0.8;
}
/* Enhanced image hover effects */
.pswp__img {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pswp__img.pswp__img--zoomed {
cursor: move;
}
/* Gallery navigation arrows */
.pswp__button--arrow--left,
.pswp__button--arrow--right {
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(10px);
border-radius: 50%;
width: 50px;
height: 50px;
margin: 20px;
}
.pswp__button--arrow--left:hover,
.pswp__button--arrow--right:hover {
background: rgba(0, 0, 0, 0.7) !important;
}
/* Touch-friendly tap areas */
@media (pointer: coarse) {
.gallery-thumbnail {
min-width: 60px;
min-height: 60px;
}
.gallery-slideshow-controls button {
min-width: 50px;
min-height: 50px;
}
}
/* Smooth transitions */
.pswp--animate_opacity {
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
.pswp__bg {
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Loading state */
.pswp__preloader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pswp__preloader__icn {
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: gallery-spin 1s linear infinite;
}
@keyframes gallery-spin {
to { transform: rotate(360deg); }
}
/* Error state */
.pswp__error-msg {
background: rgba(0, 0, 0, 0.8);
color: #ff6b6b;
padding: 20px;
border-radius: 8px;
max-width: 400px;
text-align: center;
}
/* Accessibility improvements */
.pswp__button:focus-visible {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
.gallery-thumbnail:focus-visible {
outline: 2px solid #4a9eff;
outline-offset: -2px;
}
/* Dark theme adjustments */
body.theme-dark .gallery-thumbnail-strip {
background: rgba(0, 0, 0, 0.8);
}
body.theme-dark .gallery-slideshow-controls button {
background: rgba(255, 255, 255, 0.8);
color: #000;
}
body.theme-dark .gallery-slideshow-controls button:hover {
background: rgba(255, 255, 255, 0.95);
}
/* Light theme adjustments */
body.theme-light .pswp__bg {
background: rgba(255, 255, 255, 0.95);
}
body.theme-light .gallery-counter,
body.theme-light .gallery-keyboard-hints {
background: rgba(0, 0, 0, 0.8);
color: white;
}
/* Mobile-specific styles */
@media (max-width: 768px) {
.gallery-thumbnail-strip {
bottom: 40px;
padding: 6px;
gap: 4px;
}
.gallery-thumbnail {
width: 50px !important;
height: 50px !important;
}
.gallery-slideshow-controls {
top: 10px;
right: 10px;
}
.gallery-counter {
font-size: 12px;
padding: 6px 10px;
}
.pswp__button--arrow--left,
.pswp__button--arrow--right {
width: 40px;
height: 40px;
margin: 10px;
}
}
/* Tablet-specific styles */
@media (min-width: 769px) and (max-width: 1024px) {
.gallery-thumbnail-strip {
max-width: 80%;
}
.gallery-thumbnail {
width: 70px !important;
height: 70px !important;
}
}
/* High-DPI display optimizations */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.gallery-thumbnail img {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.gallery-thumbnail,
.gallery-slideshow-controls button,
.pswp__img,
.pswp--animate_opacity,
.pswp__bg {
transition: none !important;
animation: none !important;
}
}
/* Print styles */
@media print {
.gallery-thumbnail-strip,
.gallery-slideshow-controls,
.gallery-counter,
.gallery-keyboard-hints {
display: none !important;
}
}

View File

@@ -1,528 +0,0 @@
/**
* PhotoSwipe Mobile & Accessibility Styles
* Phase 6: Complete mobile optimization and WCAG 2.1 AA compliance
*/
/* ==========================================================================
Touch Target Optimization (WCAG 2.1 Success Criterion 2.5.5)
========================================================================== */
/* Ensure all interactive elements meet minimum 44x44px touch target */
.pswp__button,
.pswp__button--arrow--left,
.pswp__button--arrow--right,
.pswp__button--close,
.pswp__button--zoom,
.pswp__button--fs,
.gallery-thumbnail,
.photoswipe-bottom-sheet button {
min-width: 44px !important;
min-height: 44px !important;
display: flex;
align-items: center;
justify-content: center;
}
/* Increase touch target padding on mobile */
@media (pointer: coarse) {
.pswp__button {
padding: 12px;
}
/* Larger hit areas for navigation arrows */
.pswp__button--arrow--left,
.pswp__button--arrow--right {
width: 60px !important;
height: 100px !important;
}
}
/* ==========================================================================
Focus Indicators (WCAG 2.1 Success Criterion 2.4.7)
========================================================================== */
/* High visibility focus indicators */
.pswp__button:focus,
.pswp__button:focus-visible,
.gallery-thumbnail:focus,
.photoswipe-bottom-sheet button:focus,
.photoswipe-focused {
outline: 3px solid #4A90E2 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.3);
}
/* Remove default browser outline */
.pswp__button:focus:not(:focus-visible) {
outline: none;
}
/* Focus indicator for images */
.pswp__img:focus {
outline: 3px solid #4A90E2;
outline-offset: -3px;
}
/* Skip link styles */
.photoswipe-skip-link {
position: absolute;
left: -10000px;
top: 0;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
z-index: 100000;
border-radius: 4px;
}
.photoswipe-skip-link:focus {
left: 10px !important;
top: 10px !important;
width: auto !important;
height: auto !important;
}
/* ==========================================================================
Mobile UI Adaptations
========================================================================== */
/* Mobile-optimized toolbar */
@media (max-width: 768px) {
.pswp__top-bar {
height: 60px;
background: rgba(0, 0, 0, 0.8);
}
.pswp__button {
width: 50px;
height: 50px;
}
/* Reposition counter for mobile */
.pswp__counter {
top: auto;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
}
}
/* Bottom sheet for mobile controls */
.photoswipe-bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.95);
padding: env(safe-area-inset-bottom, 20px) 20px;
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.photoswipe-bottom-sheet.active {
transform: translateY(0);
}
.photoswipe-bottom-sheet button {
background: none;
border: 2px solid transparent;
color: white;
font-size: 24px;
padding: 10px;
border-radius: 50%;
transition: all 0.2s;
}
.photoswipe-bottom-sheet button:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.95);
}
/* Swipe indicators */
.photoswipe-swipe-indicators {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
animation: fadeInOut 3s ease-in-out;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
20%, 80% { opacity: 0.7; }
}
/* Gesture hints */
.photoswipe-gesture-hints {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 12px 24px;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* Context menu for long press */
.photoswipe-context-menu {
background: var(--theme-background-color, white);
border: 1px solid var(--theme-border-color, #ccc);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
overflow: hidden;
min-width: 200px;
}
.photoswipe-context-menu button {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.photoswipe-context-menu button:hover,
.photoswipe-context-menu button:focus {
background: var(--theme-hover-background, rgba(0, 0, 0, 0.05));
}
/* ==========================================================================
Responsive Breakpoints
========================================================================== */
/* Small phones (< 375px) */
@media (max-width: 374px) {
.pswp__button {
width: 40px;
height: 40px;
}
.gallery-thumbnail-strip {
padding: 5px !important;
}
.gallery-thumbnail {
width: 60px !important;
height: 60px !important;
}
}
/* Tablets (768px - 1024px) */
@media (min-width: 768px) and (max-width: 1024px) {
.pswp__button {
width: 48px;
height: 48px;
}
.gallery-thumbnail {
width: 90px !important;
height: 90px !important;
}
}
/* Landscape orientation adjustments */
@media (orientation: landscape) and (max-height: 500px) {
.pswp__top-bar {
height: 44px;
}
.pswp__button {
width: 44px;
height: 44px;
}
.gallery-thumbnail-strip {
bottom: 40px !important;
max-height: 60px;
}
.gallery-thumbnail {
width: 50px !important;
height: 50px !important;
}
}
/* ==========================================================================
Accessibility Enhancements
========================================================================== */
/* Screen reader only content */
.photoswipe-sr-only,
.photoswipe-live-region,
.photoswipe-aria-live {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
/* Keyboard help dialog */
.photoswipe-keyboard-help {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--theme-background-color, white);
color: var(--theme-text-color, black);
padding: 30px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
z-index: 10001;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.photoswipe-keyboard-help h2 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
}
.photoswipe-keyboard-help dl {
margin: 0;
padding: 0;
}
.photoswipe-keyboard-help dt {
float: left;
clear: left;
width: 120px;
margin-bottom: 10px;
}
.photoswipe-keyboard-help dd {
margin-left: 140px;
margin-bottom: 10px;
}
.photoswipe-keyboard-help kbd {
display: inline-block;
padding: 3px 8px;
background: var(--theme-kbd-background, #f0f0f0);
border: 1px solid var(--theme-border-color, #ccc);
border-radius: 4px;
font-family: monospace;
font-size: 14px;
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
}
.photoswipe-keyboard-help .close-help {
margin-top: 20px;
padding: 10px 20px;
background: var(--theme-primary-color, #4A90E2);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.photoswipe-keyboard-help .close-help:hover,
.photoswipe-keyboard-help .close-help:focus {
background: var(--theme-primary-hover, #357ABD);
}
/* ==========================================================================
High Contrast Mode Support
========================================================================== */
@media (prefers-contrast: high) {
.pswp__bg {
background: #000 !important;
}
.pswp__button {
background: #000 !important;
border: 2px solid #fff !important;
}
.pswp__button svg {
fill: #fff !important;
}
.pswp__counter {
background: #000 !important;
color: #fff !important;
border: 2px solid #fff !important;
}
.gallery-thumbnail {
border-width: 3px !important;
}
.photoswipe-keyboard-help {
background: #000 !important;
color: #fff !important;
border: 2px solid #fff !important;
}
.photoswipe-keyboard-help kbd {
background: #fff !important;
color: #000 !important;
border-color: #fff !important;
}
}
/* Windows High Contrast Mode */
@media (-ms-high-contrast: active) {
.pswp__button {
border: 2px solid WindowText !important;
}
.pswp__counter {
border: 2px solid WindowText !important;
}
}
/* ==========================================================================
Reduced Motion Support (WCAG 2.1 Success Criterion 2.3.3)
========================================================================== */
@media (prefers-reduced-motion: reduce) {
/* Disable all animations */
.pswp *,
.pswp *::before,
.pswp *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Remove slide transitions */
.pswp__container {
transition: none !important;
}
/* Remove zoom animations */
.pswp__img {
transition: none !important;
}
/* Instant show/hide for indicators */
.photoswipe-swipe-indicators,
.photoswipe-gesture-hints {
animation: none !important;
transition: none !important;
}
}
/* ==========================================================================
Performance Optimizations
========================================================================== */
/* GPU acceleration for smooth animations */
.pswp__container,
.pswp__img,
.pswp__zoom-wrap {
will-change: transform;
transform: translateZ(0);
}
/* Optimize rendering for low-end devices */
@media (max-width: 768px) and (max-resolution: 2dppx) {
.pswp__img {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* Reduce visual complexity on low-end devices */
.low-performance-mode .pswp__button {
box-shadow: none !important;
}
.low-performance-mode .gallery-thumbnail {
box-shadow: none !important;
transition: none !important;
}
/* ==========================================================================
Battery Optimization Mode
========================================================================== */
.battery-saver-mode .pswp__img {
filter: brightness(0.9);
}
.battery-saver-mode .pswp__button {
opacity: 0.8;
}
.battery-saver-mode .gallery-thumbnail-strip {
display: none;
}
/* ==========================================================================
Print Styles
========================================================================== */
@media print {
.pswp {
display: none !important;
}
}
/* ==========================================================================
Custom Scrollbar for Mobile
========================================================================== */
.gallery-thumbnail-strip::-webkit-scrollbar {
height: 4px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* ==========================================================================
Safe Area Insets (for devices with notches)
========================================================================== */
.pswp__top-bar {
padding-top: env(safe-area-inset-top);
}
.photoswipe-bottom-sheet {
padding-bottom: env(safe-area-inset-bottom);
}
.pswp__button--arrow--left {
left: env(safe-area-inset-left, 10px);
}
.pswp__button--arrow--right {
right: env(safe-area-inset-right, 10px);
}

View File

@@ -1,253 +0,0 @@
/**
* Media Viewer Styles for Trilium Notes
* Customizes PhotoSwipe appearance to match Trilium's theme
*/
/* Base PhotoSwipe container customization */
.pswp {
--pswp-bg: rgba(0, 0, 0, 0.95);
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
--pswp-icon-color: #fff;
--pswp-icon-color-secondary: rgba(255, 255, 255, 0.75);
--pswp-icon-stroke-color: #fff;
--pswp-icon-stroke-width: 1px;
--pswp-error-text-color: #f44336;
}
/* Dark theme adjustments */
body.theme-dark .pswp,
body.theme-next-dark .pswp {
--pswp-bg: rgba(0, 0, 0, 0.95);
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
}
/* Light theme adjustments */
body.theme-light .pswp,
body.theme-next-light .pswp {
--pswp-bg: rgba(0, 0, 0, 0.9);
--pswp-placeholder-bg: rgba(50, 50, 50, 0.8);
}
/* Toolbar and controls styling */
.pswp__top-bar {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
}
.pswp__button {
transition: opacity 0.2s ease-in-out;
}
.pswp__button:hover {
opacity: 1;
}
/* Counter styling */
.pswp__counter {
font-family: var(--main-font-family);
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* Caption styling */
.pswp__caption {
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
}
.pswp__caption__center {
text-align: center;
font-family: var(--main-font-family);
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
padding: 10px 20px;
}
/* Image styling */
.pswp__img {
cursor: zoom-in;
}
.pswp__img--placeholder {
background-color: var(--pswp-placeholder-bg);
}
.pswp--zoomed-in .pswp__img {
cursor: grab;
}
.pswp--dragging .pswp__img {
cursor: grabbing;
}
/* Loading indicator */
.pswp__preloader {
width: 44px;
height: 44px;
}
.pswp__preloader__icn {
width: 20px;
height: 20px;
}
/* Error message styling */
.pswp__error-msg {
font-family: var(--main-font-family);
font-size: 14px;
color: var(--pswp-error-text-color);
text-align: center;
padding: 20px;
}
/* Thumbnails strip (for future implementation) */
.pswp__thumbnails {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
padding: 10px;
gap: 10px;
overflow-x: auto;
z-index: 1;
}
.pswp__thumbnail {
width: 60px;
height: 60px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
object-fit: cover;
border: 2px solid transparent;
}
.pswp__thumbnail:hover {
opacity: 0.9;
}
.pswp__thumbnail--active {
opacity: 1;
border-color: var(--main-border-color);
}
/* Animations */
.pswp--open {
animation: pswpFadeIn 0.25s ease-out;
}
.pswp--closing {
animation: pswpFadeOut 0.25s ease-out;
}
@keyframes pswpFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pswpFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Zoom animation */
.pswp__zoom-wrap {
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.pswp__caption__center {
font-size: 12px;
padding: 8px 16px;
}
.pswp__counter {
font-size: 12px;
}
.pswp__thumbnails {
height: 60px;
}
.pswp__thumbnail {
width: 45px;
height: 45px;
}
}
/* Integration with Trilium's note context */
.media-viewer-trigger {
cursor: zoom-in;
transition: opacity 0.2s;
}
.media-viewer-trigger:hover {
opacity: 0.9;
}
/* Gallery mode indicators */
.media-viewer-gallery-indicator {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-family: var(--main-font-family);
pointer-events: none;
z-index: 1;
}
/* Fullscreen mode adjustments */
.pswp--fs {
background-color: black;
}
.pswp--fs .pswp__top-bar {
background-color: rgba(0, 0, 0, 0.7);
}
/* Accessibility improvements */
.pswp__button:focus {
outline: 2px solid var(--main-border-color);
outline-offset: 2px;
}
.pswp__img:focus {
outline: none;
}
/* Custom toolbar buttons */
.pswp__button--download {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'%3E%3C/path%3E%3Cpolyline points='7 10 12 15 17 10'%3E%3C/polyline%3E%3Cline x1='12' y1='15' x2='12' y2='3'%3E%3C/line%3E%3C/svg%3E");
background-size: 24px 24px;
}
.pswp__button--info {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='16' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12.01' y2='8'%3E%3C/line%3E%3C/svg%3E");
background-size: 24px 24px;
}
/* Print styles */
@media print {
.pswp {
display: none !important;
}
}

View File

@@ -967,7 +967,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:", "enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
"start_session_button": "开始受保护的会话", "start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
"started": "受保护的会话已启动。", "started": "受保护的会话已启动。",
"wrong_password": "密码错误。", "wrong_password": "密码错误。",
"protecting-finished-successfully": "保护操作已成功完成。", "protecting-finished-successfully": "保护操作已成功完成。",
@@ -1028,7 +1028,7 @@
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息", "error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}", "successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}", "successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
"no_anonymized_database_yet": "尚无匿名化数据库" "no_anonymized_database_yet": "尚无匿名化数据库"
}, },
"database_integrity_check": { "database_integrity_check": {
"title": "数据库完整性检查", "title": "数据库完整性检查",
@@ -1333,9 +1333,9 @@
"oauth_title": "OAuth/OpenID 认证", "oauth_title": "OAuth/OpenID 认证",
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google的账号登录网站来验证您的身份。默认的身份提供者是 Google但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。", "oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google的账号登录网站来验证您的身份。默认的身份提供者是 Google但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
"oauth_description_warning": "要启用 OAuth/OpenID您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。", "oauth_description_warning": "要启用 OAuth/OpenID您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}", "oauth_missing_vars": "缺少以下设置项{{variables}}",
"oauth_user_account": "用户账号:", "oauth_user_account": "用户账号: ",
"oauth_user_email": "用户邮箱:", "oauth_user_email": "用户邮箱: ",
"oauth_user_not_logged_in": "未登录!" "oauth_user_not_logged_in": "未登录!"
}, },
"shortcuts": { "shortcuts": {
@@ -1357,7 +1357,7 @@
"enable": "启用拼写检查", "enable": "启用拼写检查",
"language_code_label": "语言代码", "language_code_label": "语言代码",
"language_code_placeholder": "例如 \"en-US\", \"de-AT\"", "language_code_placeholder": "例如 \"en-US\", \"de-AT\"",
"multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。", "multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。 ",
"available_language_codes_label": "可用的语言代码:", "available_language_codes_label": "可用的语言代码:",
"restart-required": "拼写检查选项的更改将在应用重启后生效。" "restart-required": "拼写检查选项的更改将在应用重启后生效。"
}, },
@@ -1992,9 +1992,6 @@
"help_title": "显示关于此画面的更多信息" "help_title": "显示关于此画面的更多信息"
}, },
"call_to_action": { "call_to_action": {
"next_theme_title": "新的 Trilium 主题已进入稳定版",
"next_theme_message": "有一段时间,我们一直在设计新的主题,为了让应用程序看起来更加现代。",
"next_theme_button": "切换至新的 Trilium 主题",
"background_effects_title": "背景效果现已推出稳定版本", "background_effects_title": "背景效果现已推出稳定版本",
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。", "background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
"background_effects_button": "启用背景效果" "background_effects_button": "启用背景效果"

View File

@@ -1994,9 +1994,9 @@
"help_title": "Display more information about this screen" "help_title": "Display more information about this screen"
}, },
"call_to_action": { "call_to_action": {
"next_theme_title": "The new Trilium theme is now stable", "next_theme_title": "Try the new Trilium theme",
"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_message": "You are currently using the legacy theme, would you like to try the new theme?",
"next_theme_button": "Switch to the new Trilium theme", "next_theme_button": "Try the new theme",
"background_effects_title": "Background effects are now stable", "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_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" "background_effects_button": "Enable background effects"

View File

@@ -0,0 +1,22 @@
{
"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به احتمال زیاد ناشی از خطای غیرمنتظره در اجرای ناموفق یک اسکریپت است. برنامه را در مد ایمن اجرا کنید و خطا را بررسی نمایید."
}
},
"add_link": {
"add_link": "افزودن لینک",
"note": "یادداشت"
}
}

View File

@@ -0,0 +1,144 @@
{
"about": {
"title": "Lisätietoja Trilium Notes:ista",
"homepage": "Kotisivu:",
"app_version": "Sovelluksen versio:",
"db_version": "Tietokannan versio:",
"build_date": "Koontipäivämäärä:",
"data_directory": "Datakansio:",
"sync_version": "Synkronoinnin versio:",
"build_revision": "Sovelluksen versio:"
},
"toast": {
"critical-error": {
"title": "Kriittinen virhe"
},
"widget-error": {
"title": "Widgetin luonti epäonnistui"
}
},
"add_link": {
"add_link": "Lisää linkki",
"link_title": "Linkin otsikko",
"button_add_link": "Lisää linkki",
"note": "Muistio",
"search_note": "etsi muistiota sen nimellä"
},
"branch_prefix": {
"prefix": "Etuliite: ",
"save": "Tallenna"
},
"bulk_actions": {
"bulk_actions": "Massatoiminnot",
"available_actions": "Saatavilla olevat toiminnot",
"chosen_actions": "Valitut toiminnot",
"execute_bulk_actions": "Toteuta massatoiminnot",
"bulk_actions_executed": "Massatoiminnot on toteutettu onnistuneesti.",
"none_yet": "Ei vielä... lisää toiminto klikkaamalla jotiain yllä saatavilla olevaa yltä.",
"labels": "Merkit",
"relations": "Suhteet",
"notes": "Muistiot",
"other": "Muut",
"affected_notes": "Vaikuttaa muistioihin"
},
"clone_to": {
"clone_notes_to": "Kopioi muistiot...",
"help_on_links": "Apua linkkeihin",
"notes_to_clone": "Kopioitavat muistiot",
"target_parent_note": "Kohteen päämuistio",
"search_for_note_by_its_name": "ensi muistiota sen nimellä",
"cloned_note_prefix_title": "Kopioitu muistia näytetään puussa annetulla etuliitteellä",
"prefix_optional": "Etuliite (valinnainen)",
"clone_to_selected_note": "Kopioi valittuun muistioon",
"note_cloned": "Muistio \"{{clonedTitle}}\" on kopioitu \"{{targetTitle}}\""
},
"confirm": {
"confirmation": "Vahvistus",
"cancel": "Peruuta",
"ok": "OK",
"also_delete_note": "Poista myös muistio"
},
"delete_notes": {
"delete_notes_preview": "Poista muistion esikatselu",
"close": "Sulje",
"notes_to_be_deleted": "Seuraavat muistiot tullaan poistamaan ({{notesCount}})",
"no_note_to_delete": "Muistioita ei poisteta (vain kopiot).",
"cancel": "Peruuta",
"ok": "OK"
},
"export": {
"export_note_title": "Vie muistio",
"close": "Sulje",
"format_html": "HTML - suositeltu, sillä se säilyttää kaikki formatoinnit",
"format_markdown": "Markdown - tämä säilyttää suurimman osan formatoinneista.",
"opml_version_1": "OPML v1.0 - pelkkä teksti",
"opml_version_2": "OPML v2.0 - sallii myös HTML:n",
"export": "Vie",
"choose_export_type": "Valitse ensin viennin tyyppi",
"export_status": "Viennin tila",
"export_in_progress": "Vienti käynnissä: {{progressCount}}",
"export_finished_successfully": "Vienti valmistui onnistuneesti.",
"format_pdf": "PDF - tulostukseen ja jakamiseen."
},
"help": {
"title": "Lunttilappu",
"noteNavigation": "Muistion navigointi",
"goUpDown": "mene ylös/alas muistioiden listassa",
"collapseExpand": "pienennä/suurenna solmu",
"notSet": "ei asetettu",
"goBackForwards": "mene taaksepäin/eteenpäin historiassa",
"jumpToParentNote": "Hyppää ylempään muistioon",
"collapseWholeTree": "pienennä koko muistio puu",
"onlyInDesktop": "Vain työpöytänäkymässä (Electron build)",
"openEmptyTab": "Avaa tyhjä välilehti",
"closeActiveTab": "sulje aktiivinen välilehti",
"activateNextTab": "aktivoi seuraava välilehti",
"activatePreviousTab": "aktivoi edellinen välilehti",
"creatingNotes": "Luo muistiota",
"movingCloningNotes": "Siirrä / kopioi muistioita",
"moveNoteUpHierarchy": "siirrä muistio ylöspäin listassa",
"selectNote": "valitse muistio",
"editingNotes": "Muokkaa solmua",
"createEditLink": "luo / muokkaa ulkoista linkkiä",
"createInternalLink": "luo sisäinen linkki",
"insertDateTime": "lisää nykyinen päivämäärä ja aika hiiren kohdalle",
"troubleshooting": "Vianmääritys",
"reloadFrontend": "lataa Trilium:in käyttöliittymä",
"showDevTools": "näytä kehittäjätyökalut",
"showSQLConsole": "näytä SQL konsoli",
"other": "Muut"
},
"import": {
"importIntoNote": "Tuo muistioon",
"chooseImportFile": "Valitse tuonnin tiedosto",
"options": "Valinnat",
"safeImport": "Turvallinen tuonti",
"shrinkImages": "Kutista kuvat",
"replaceUnderscoresWithSpaces": "Korvaa alaviivat väleillä tuotujen muistioiden tiedostonimissä",
"import": "Tuo",
"failed": "Tuonti epäonnistui: {{message}}.",
"html_import_tags": {
"title": "HTML Tuonnin Tunnisteet",
"placeholder": "Lisää HTML tunnisteet, yksi per rivi"
},
"import-status": "Tuonnin tila",
"in-progress": "Tuonti vaiheessa: {{progress}}",
"successful": "Tuonti valmistui onnistuneesti."
},
"include_note": {
"dialog_title": "Sisällytä muistio",
"label_note": "Muistio",
"placeholder_search": "etsi muistiota sen nimellä",
"box_size_small": "pieni (~ 10 riviä)",
"box_size_medium": "keskisuuri (~ 30 riviä)",
"button_include": "Sisällytä muistio"
},
"info": {
"modalTitle": "Info viesti",
"closeButton": "Sulje",
"okButton": "OK"
},
"jump_to_note": {
"search_button": "Etsi koko tekstistä"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -249,7 +249,7 @@
}, },
"prompt": { "prompt": {
"title": "Prompt", "title": "Prompt",
"ok": "OK <kbd>enter</kbd>", "ok": "OK",
"defaultTitle": "Prompt" "defaultTitle": "Prompt"
}, },
"protected_session_password": { "protected_session_password": {
@@ -257,7 +257,7 @@
"help_title": "Ajuda sobre notas protegidas", "help_title": "Ajuda sobre notas protegidas",
"close_label": "Fechar", "close_label": "Fechar",
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:", "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>" "start_button": "Iniciar sessão protegida"
}, },
"recent_changes": { "recent_changes": {
"title": "Alterações recentes", "title": "Alterações recentes",
@@ -306,12 +306,12 @@
"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.", "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", "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.", "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>" "sort": "Ordenar"
}, },
"upload_attachments": { "upload_attachments": {
"upload_attachments_to_note": "Enviar anexos para a nota", "upload_attachments_to_note": "Enviar anexos para a nota",
"choose_files": "Escolher arquivos", "choose_files": "Escolher arquivos",
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para", "files_will_be_uploaded": "Os arquivos serão enviados como anexos para {{noteTitle}}",
"options": "Opções", "options": "Opções",
"shrink_images": "Reduzir imagens", "shrink_images": "Reduzir imagens",
"upload": "Enviar", "upload": "Enviar",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
{
"add_link": {
"add_link": "Додати посилання",
"help_on_links": "Довідка щодо посилань",
"note": "Нотатка",
"search_note": "Знайти нотатку за ім'ям",
"link_title_mirrors": "заголовок посилання відображає назву нотатки",
"link_title_arbitrary": "свій заголовок посилання",
"link_title": "Заголовок посилання",
"button_add_link": "Додати посилання"
},
"branch_prefix": {
"save": "Зберегти",
"edit_branch_prefix": "Редагувати префікс гілки",
"help_on_tree_prefix": "Довідка щодо префіксів гілок",
"prefix": "Префікс: ",
"branch_prefix_saved": "Префікс гілки збережено."
},
"about": {
"app_version": "Версія програми:",
"db_version": "Версія БД:",
"build_date": "Дата збірки:",
"build_revision": "Ревізія збірки:",
"data_directory": "Директорія даних:",
"homepage": "Домашня сторінка:",
"title": "Про Trilium Notes"
},
"global_menu": {
"about": "Про Trilium Notes"
},
"modal": {
"help_title": "Показати більше інформації про це вікно"
},
"toast": {
"critical-error": {
"title": "Критична помилка"
}
},
"bulk_actions": {
"bulk_actions": "Масові дії",
"affected_notes": "Зачеплені нотатки",
"available_actions": "Доступні дії",
"chosen_actions": "Обрані дії",
"execute_bulk_actions": "Виконати масові дії",
"bulk_actions_executed": "Масові дії успішно виконано.",
"none_yet": "Поки ніяких.. Додайте дію натиснувши на одну з наданих вище."
},
"clone_to": {
"clone_notes_to": "Клонувати нотатки до...",
"target_parent_note": "Цільова батьківська нотатка",
"search_for_note_by_its_name": "Знайти нотатку за ім'ям"
},
"clipboard": {
"copied": "Нотатку(-и) було скопійовано в буфер.",
"copy_failed": "Не вдалося скопіювати в буфер через проблеми з дозволами.",
"copy_success": "Скопійовано в буфер."
},
"entrypoints": {
"sql-error": "Виникла помилка при виконанні запиту SQL: {{message}}"
},
"branches": {
"undeleting-notes-finished-successfully": "Нотатки вдало відновлено.",
"undeleting-notes-in-progress": "Відновлюємо нотатки: {{count}}",
"delete-notes-in-progress": "Видаляємо нотатки: {{count}}",
"delete-finished-successfully": "Нотатки вдало видалено."
},
"launcher_context_menu": {
"add-spacer": "Додати розділювач",
"reset": "Скинути"
},
"editable-text": {
"auto-detect-language": "Автовизначена"
},
"highlighting": {
"color-scheme": "Схема кольорів"
},
"code_block": {
"copy_title": "Скопіювати в буфер"
},
"classic_editor_toolbar": {
"title": "Форматування"
},
"editor": {
"title": "Редактор"
},
"editing": {
"editor_type": {
"label": "Панель інструментів форматування"
}
}
}

View File

@@ -1,69 +1,77 @@
{ {
"about": { "about": {
"homepage": "Trang chủ:", "homepage": "Trang chủ:",
"title": "Về Trilium Notes" "title": "Về Trilium Notes"
}, },
"add_link": { "add_link": {
"add_link": "Thêm liên kết", "add_link": "Thêm liên kết",
"button_add_link": "Thêm liên kết" "button_add_link": "Thêm liên kết"
}, },
"bulk_actions": { "bulk_actions": {
"other": "Khác" "other": "Khác"
}, },
"branch_prefix": { "branch_prefix": {
"save": "Lưu" "save": "Lưu"
}, },
"confirm": { "confirm": {
"ok": "OK", "ok": "OK",
"cancel": "Huỷ" "cancel": "Huỷ"
}, },
"delete_notes": { "delete_notes": {
"close": "Đóng", "close": "Đóng",
"ok": "OK", "ok": "OK",
"cancel": "Huỷ" "cancel": "Huỷ"
}, },
"export": { "export": {
"close": "Đóng" "close": "Đóng"
}, },
"help": { "help": {
"other": "Khác" "other": "Khác"
}, },
"toast": { "toast": {
"critical-error": { "critical-error": {
"title": "Lỗi nghiêm trọng" "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"
} }
},
"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"
},
"add_label": {
"add_label": "Thêm nhãn",
"label_name_placeholder": "tên nhãn",
"help_text_item2": "hoặc thay đổi giá trị của nhãn có sẵn"
},
"rename_label": {
"rename_label": "Đặt lại tên nhãn"
}
} }

View File

@@ -9,9 +9,6 @@ import contentRenderer from "../services/content_renderer.js";
import toastService from "../services/toast.js"; import toastService from "../services/toast.js";
import type FAttachment from "../entities/fattachment.js"; import type FAttachment from "../entities/fattachment.js";
import type { EventData } from "../components/app_context.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*/` const TPL = /*html*/`
<div class="attachment-detail-widget"> <div class="attachment-detail-widget">
@@ -68,12 +65,6 @@ const TPL = /*html*/`
.attachment-content-wrapper img { .attachment-content-wrapper img {
margin: 10px; 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 { .attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
@@ -86,24 +77,6 @@ const TPL = /*html*/`
max-width: 90%; max-width: 90%;
object-fit: contain; 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 { .attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%); filter: contrast(10%);
@@ -115,9 +88,7 @@ const TPL = /*html*/`
<div class="attachment-actions-container"></div> <div class="attachment-actions-container"></div>
<h4 class="attachment-title"></h4> <h4 class="attachment-title"></h4>
<div class="attachment-details"></div> <div class="attachment-details"></div>
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note"> <div style="flex: 1 1;"></div>
<span class="bx bx-arrow-back"></span> Back to Note
</button>
</div> </div>
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div> <div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
@@ -153,14 +124,6 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html()); this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
this.$wrapper = this.$widget.find(".attachment-detail-wrapper"); this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view"); 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) { if (!this.isFullDetail) {
const $link = await linkService.createLink(this.attachment.ownerId, { const $link = await linkService.createLink(this.attachment.ownerId, {
@@ -207,92 +170,7 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render()); this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail }); const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper"); this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
$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() { async copyAttachmentLinkToClipboard() {
@@ -326,43 +204,4 @@ 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();
}
} }

View File

@@ -414,7 +414,7 @@ export default class GlobalMenuWidget extends BasicWidget {
} }
async fetchLatestVersion() { async fetchLatestVersion() {
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Notes/releases/latest"; const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
const resp = await fetch(RELEASES_API_URL); const resp = await fetch(RELEASES_API_URL);
const data = await resp.json(); const data = await resp.json();

View File

@@ -25,7 +25,7 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
<Modal <Modal
className="call-to-action" className="call-to-action"
size="md" size="md"
title="New features" title={activeItem.title}
show={shown} show={shown}
onHidden={() => setShown(false)} onHidden={() => setShown(false)}
footerAlignment="between" footerAlignment="between"
@@ -43,7 +43,6 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
)} )}
</>} </>}
> >
<h4>{activeItem.title}</h4>
<p>{activeItem.message}</p> <p>{activeItem.message}</p>
</Modal> </Modal>
) )

View File

@@ -65,7 +65,7 @@ const CALL_TO_ACTIONS: CallToAction[] = [
id: "background_effects", id: "background_effects",
title: t("call_to_action.background_effects_title"), title: t("call_to_action.background_effects_title"),
message: t("call_to_action.background_effects_message"), message: t("call_to_action.background_effects_message"),
enabled: () => utils.isElectron() && window.glob.platform === "win32" && isNextTheme() && !options.is("backgroundEffects"), enabled: () => false,
buttons: [ buttons: [
{ {
text: t("call_to_action.background_effects_button"), text: t("call_to_action.background_effects_button"),

View File

@@ -1,549 +0,0 @@
/**
* Embedded Image Gallery Widget
* Handles image galleries within text notes and other content types
*/
import BasicWidget from "./basic_widget.js";
import galleryManager from "../services/gallery_manager.js";
import mediaViewer from "../services/media_viewer.js";
import type { GalleryItem, GalleryConfig } from "../services/gallery_manager.js";
import type { MediaViewerCallbacks } from "../services/media_viewer.js";
import utils from "../services/utils.js";
const TPL = /*html*/`
<style>
.embedded-gallery-trigger {
position: relative;
display: inline-block;
cursor: pointer;
user-select: none;
}
.embedded-gallery-trigger::after {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.embedded-gallery-trigger:hover::after {
opacity: 1;
}
.embedded-gallery-trigger.has-gallery::after {
content: '\\f0660'; /* Gallery icon from boxicons font */
font-family: 'boxicons';
color: white;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
line-height: 32px;
text-align: center;
}
.gallery-indicator {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
pointer-events: none;
z-index: 1;
}
.image-grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
padding: 16px;
}
.image-grid-item {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
background: var(--accented-background-color);
}
.image-grid-item:hover {
transform: scale(1.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.image-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-grid-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: white;
padding: 8px;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.image-grid-item:hover .image-grid-caption {
opacity: 1;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-grid-view {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 8px;
}
.gallery-indicator {
font-size: 10px;
padding: 2px 4px;
}
}
</style>
`;
interface ImageElement {
element: HTMLImageElement;
src: string;
alt?: string;
title?: string;
caption?: string;
noteId?: string;
index: number;
}
export default class EmbeddedImageGallery extends BasicWidget {
private galleryItems: GalleryItem[] = [];
private imageElements: Map<HTMLElement, ImageElement> = new Map();
private observer?: MutationObserver;
private processingQueue: Set<HTMLElement> = new Set();
doRender(): JQuery<HTMLElement> {
this.$widget = $(TPL);
this.setupMutationObserver();
return this.$widget;
}
/**
* Initialize gallery for a container element
*/
async initializeGallery(
container: HTMLElement | JQuery<HTMLElement>,
options?: {
selector?: string;
autoEnhance?: boolean;
gridView?: boolean;
galleryConfig?: GalleryConfig;
}
): Promise<void> {
const $container = $(container);
const selector = options?.selector || 'img';
const autoEnhance = options?.autoEnhance !== false;
const gridView = options?.gridView || false;
// Find all images in the container
const images = $container.find(selector).toArray() as HTMLImageElement[];
if (images.length === 0) {
return;
}
// Create gallery items
this.galleryItems = await this.createGalleryItems(images, $container);
if (gridView) {
// Create grid view
this.createGridView($container, this.galleryItems);
} else if (autoEnhance) {
// Enhance individual images
this.enhanceImages(images);
}
}
/**
* Create gallery items from image elements
*/
private async createGalleryItems(
images: HTMLImageElement[],
$container: JQuery<HTMLElement>
): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
// Skip already processed images
if (img.dataset.galleryProcessed === 'true') {
continue;
}
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${i + 1}`,
title: img.title || img.alt,
element: img,
index: i,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Extract caption from figure element
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 note ID in data attributes or URL
item.noteId = this.extractNoteId(img);
// Get dimensions if not available
if (!item.width || !item.height) {
try {
const dimensions = await mediaViewer.getImageDimensions(img.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
console.warn('Failed to get image dimensions:', error);
}
}
items.push(item);
// Store image element data
this.imageElements.set(img, {
element: img,
src: img.src,
alt: item.alt,
title: item.title,
caption: item.caption,
noteId: item.noteId,
index: i
});
// Mark as processed
img.dataset.galleryProcessed = 'true';
}
return items;
}
/**
* Enhance individual images with gallery functionality
*/
private enhanceImages(images: HTMLImageElement[]): void {
images.forEach((img, index) => {
const $img = $(img);
// Wrap image in a trigger container if not already wrapped
if (!$img.parent().hasClass('embedded-gallery-trigger')) {
$img.wrap('<span class="embedded-gallery-trigger"></span>');
}
const $trigger = $img.parent();
// Add gallery indicator if multiple images
if (this.galleryItems.length > 1) {
$trigger.addClass('has-gallery');
// Add count indicator
if (!$trigger.find('.gallery-indicator').length) {
$trigger.prepend(`
<span class="gallery-indicator" aria-label="Image ${index + 1} of ${this.galleryItems.length}">
${index + 1}/${this.galleryItems.length}
</span>
`);
}
}
// Remove any existing click handlers
$img.off('click.gallery');
// Add click handler to open gallery
$img.on('click.gallery', (e) => {
e.preventDefault();
e.stopPropagation();
this.openGallery(index);
});
// Add keyboard support
$img.attr('tabindex', '0');
$img.attr('role', 'button');
$img.attr('aria-label', `${img.alt || 'Image'} - Click to open in gallery`);
$img.on('keydown.gallery', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.openGallery(index);
}
});
});
}
/**
* Create grid view of images
*/
private createGridView($container: JQuery<HTMLElement>, items: GalleryItem[]): void {
const $grid = $('<div class="image-grid-view"></div>');
items.forEach((item, index) => {
const $gridItem = $(`
<div class="image-grid-item" data-index="${index}" tabindex="0" role="button">
<img src="${item.src}" alt="${item.alt}" loading="lazy" />
${item.caption ? `<div class="image-grid-caption">${item.caption}</div>` : ''}
</div>
`);
$gridItem.on('click', () => this.openGallery(index));
$gridItem.on('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.openGallery(index);
}
});
$grid.append($gridItem);
});
// Replace container content with grid
$container.empty().append($grid);
}
/**
* Open gallery at specified index
*/
private openGallery(startIndex: number = 0): void {
if (this.galleryItems.length === 0) {
return;
}
const config: GalleryConfig = {
showThumbnails: this.galleryItems.length > 1,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: this.galleryItems.length > 1,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log('Embedded gallery opened');
this.trigger('galleryOpened', { items: this.galleryItems, startIndex });
},
onClose: () => {
console.log('Embedded gallery closed');
this.trigger('galleryClosed');
// Restore focus to the trigger element
const currentItem = this.galleryItems[galleryManager.getGalleryState()?.currentIndex || startIndex];
if (currentItem?.element) {
(currentItem.element as HTMLElement).focus();
}
},
onChange: (index) => {
console.log('Gallery slide changed to:', index);
this.trigger('gallerySlideChanged', { index, item: this.galleryItems[index] });
},
onImageLoad: (index, item) => {
console.log('Gallery image loaded:', item.title);
},
onImageError: (index, item, error) => {
console.error('Failed to load gallery image:', error);
}
};
if (this.galleryItems.length === 1) {
// Open single image
mediaViewer.openSingle(this.galleryItems[0], {
bgOpacity: 0.95,
showHideOpacity: true,
wheelToZoom: true,
pinchToClose: true
}, callbacks);
} else {
// Open gallery
galleryManager.openGallery(this.galleryItems, startIndex, config, callbacks);
}
}
/**
* Extract note ID from image element
*/
private extractNoteId(img: HTMLImageElement): string | undefined {
// Check data attribute
if (img.dataset.noteId) {
return img.dataset.noteId;
}
// Try to extract from URL
const match = img.src.match(/\/api\/images\/([a-zA-Z0-9_]+)/);
if (match) {
return match[1];
}
return undefined;
}
/**
* Setup mutation observer to detect dynamically added images
*/
private setupMutationObserver(): void {
this.observer = new MutationObserver((mutations) => {
const imagesToProcess: HTMLImageElement[] = [];
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// Check if it's an image
if (element.tagName === 'IMG') {
imagesToProcess.push(element as HTMLImageElement);
}
// Check for images within the added element
const images = element.querySelectorAll('img');
images.forEach(img => imagesToProcess.push(img as HTMLImageElement));
}
});
});
if (imagesToProcess.length > 0) {
this.processNewImages(imagesToProcess);
}
});
}
/**
* Process newly added images
*/
private async processNewImages(images: HTMLImageElement[]): Promise<void> {
// Filter out already processed images
const newImages = images.filter(img =>
img.dataset.galleryProcessed !== 'true' &&
!this.processingQueue.has(img)
);
if (newImages.length === 0) {
return;
}
// Add to processing queue
newImages.forEach(img => this.processingQueue.add(img));
try {
// Create gallery items for new images
const newItems = await this.createGalleryItems(newImages, $(document.body));
// Add to existing gallery
this.galleryItems.push(...newItems);
// Enhance the new images
this.enhanceImages(newImages);
} finally {
// Remove from processing queue
newImages.forEach(img => this.processingQueue.delete(img));
}
}
/**
* Start observing a container for new images
*/
observeContainer(container: HTMLElement): void {
if (!this.observer) {
this.setupMutationObserver();
}
this.observer?.observe(container, {
childList: true,
subtree: true
});
}
/**
* Stop observing
*/
stopObserving(): void {
this.observer?.disconnect();
}
/**
* Refresh gallery items
*/
async refresh(): Promise<void> {
// Clear existing items
this.galleryItems = [];
this.imageElements.clear();
// Mark all images as unprocessed
$('[data-gallery-processed="true"]').removeAttr('data-gallery-processed');
// Re-initialize if there's a container
const $container = this.$widget?.parent();
if ($container?.length) {
await this.initializeGallery($container);
}
}
/**
* Get current gallery items
*/
getGalleryItems(): GalleryItem[] {
return this.galleryItems;
}
/**
* Cleanup
*/
cleanup(): void {
// Stop observing
this.stopObserving();
// Close gallery if open
if (galleryManager.isGalleryOpen()) {
galleryManager.closeGallery();
}
// Remove event handlers
$('[data-gallery-processed="true"]').off('.gallery');
// Clear data
this.galleryItems = [];
this.imageElements.clear();
this.processingQueue.clear();
super.cleanup();
}
}

View File

@@ -1,573 +0,0 @@
import { TypedBasicWidget } from "./basic_widget.js";
import Component from "../components/component.js";
import mediaViewerService from "../services/media_viewer.js";
import type { MediaItem, MediaViewerConfig, MediaViewerCallbacks } from "../services/media_viewer.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
import froca from "../services/froca.js";
import utils from "../services/utils.js";
import server from "../services/server.js";
import toastService from "../services/toast.js";
/**
* MediaViewerWidget provides a modern lightbox experience for viewing images
* and other media in Trilium Notes using PhotoSwipe 5.
*
* This widget can be used in two modes:
* 1. As a standalone viewer for a single note's media
* 2. As a gallery viewer for multiple media items
*/
export class MediaViewerWidget extends TypedBasicWidget<Component> {
private currentNoteId: string | null = null;
private galleryItems: MediaItem[] = [];
private isGalleryMode: boolean = false;
private clickHandlers: Map<HTMLElement, () => void> = new Map();
private boundKeyboardHandler: ((event: KeyboardEvent) => void) | null = null;
constructor() {
super();
this.setupGlobalHandlers();
}
/**
* Setup global event handlers for media viewing
*/
private setupGlobalHandlers(): void {
// Store bound handler for proper cleanup
this.boundKeyboardHandler = this.handleKeyboard.bind(this);
document.addEventListener('keydown', this.boundKeyboardHandler);
// Cleanup will be called by parent class
}
/**
* Handle keyboard shortcuts with error boundary
*/
private handleKeyboard(event: KeyboardEvent): void {
try {
// Only handle if viewer is open
if (!mediaViewerService.isOpen()) {
return;
}
switch (event.key) {
case 'ArrowLeft':
mediaViewerService.prev();
event.preventDefault();
break;
case 'ArrowRight':
mediaViewerService.next();
event.preventDefault();
break;
case 'Escape':
mediaViewerService.close();
event.preventDefault();
break;
}
} catch (error) {
console.error('Error handling keyboard event:', error);
}
}
/**
* Open viewer for a single image note with comprehensive error handling
*/
async openImageNote(noteId: string, config?: Partial<MediaViewerConfig>): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note || note.type !== 'image') {
toastService.showError('Note is not an image');
return;
}
const item: MediaItem = {
src: utils.createImageSrcUrl(note),
alt: note.title || `Image ${noteId}`,
title: note.title || `Image ${noteId}`,
noteId: noteId
};
// Try to get image dimensions from attributes
const widthAttr = note.getAttribute('label', 'imageWidth');
const heightAttr = note.getAttribute('label', 'imageHeight');
if (widthAttr && heightAttr) {
const width = parseInt(widthAttr.value);
const height = parseInt(heightAttr.value);
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
item.width = width;
item.height = height;
}
}
// Get dimensions dynamically if not available
if (!item.width || !item.height) {
try {
const dimensions = await mediaViewerService.getImageDimensions(item.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
console.warn('Failed to get image dimensions, using defaults:', error);
// Use default dimensions as fallback
item.width = 800;
item.height = 600;
}
}
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onViewerOpen(noteId),
onClose: () => this.onViewerClose(noteId),
onImageError: (index, errorItem, error) => this.onImageError(errorItem, error)
};
mediaViewerService.openSingle(item, config, callbacks);
this.currentNoteId = noteId;
} catch (error) {
console.error('Failed to open image note:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to open image';
toastService.showError(errorMessage);
}
}
/**
* Open viewer for multiple images (gallery mode) with isolated error handling
*/
async openGallery(noteIds: string[], startIndex: number = 0, config?: Partial<MediaViewerConfig>): Promise<void> {
try {
const items: MediaItem[] = [];
const errors: Array<{ noteId: string; error: unknown }> = [];
// Process each note with isolated error handling
await Promise.all(noteIds.map(async (noteId) => {
try {
const note = await froca.getNote(noteId);
if (!note || note.type !== 'image') {
return; // Skip non-image notes silently
}
const item: MediaItem = {
src: utils.createImageSrcUrl(note),
alt: note.title || `Image ${noteId}`,
title: note.title || `Image ${noteId}`,
noteId: noteId
};
// Try to get dimensions
const widthAttr = note.getAttribute('label', 'imageWidth');
const heightAttr = note.getAttribute('label', 'imageHeight');
if (widthAttr && heightAttr) {
const width = parseInt(widthAttr.value);
const height = parseInt(heightAttr.value);
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
item.width = width;
item.height = height;
}
}
// Use default dimensions if not available
if (!item.width || !item.height) {
item.width = 800;
item.height = 600;
}
items.push(item);
} catch (error) {
console.error(`Failed to process note ${noteId}:`, error);
errors.push({ noteId, error });
}
}));
if (items.length === 0) {
if (errors.length > 0) {
toastService.showError('Failed to load any images');
} else {
toastService.showMessage('No images to display');
}
return;
}
// Show warning if some images failed
if (errors.length > 0) {
toastService.showMessage(`Loaded ${items.length} images (${errors.length} failed)`);
}
// Validate and adjust start index
if (startIndex < 0 || startIndex >= items.length) {
console.warn(`Invalid start index ${startIndex}, using 0`);
startIndex = 0;
}
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onGalleryOpen(),
onClose: () => this.onGalleryClose(),
onChange: (index) => this.onGalleryChange(index),
onImageError: (index, item, error) => this.onImageError(item, error)
};
mediaViewerService.open(items, startIndex, config, callbacks);
this.galleryItems = items;
this.isGalleryMode = true;
} catch (error) {
console.error('Failed to open gallery:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to open gallery';
toastService.showError(errorMessage);
}
}
/**
* Open viewer for images in note content
*/
async openContentImages(noteId: string, container: HTMLElement, startIndex: number = 0): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note) {
toastService.showError('Note not found');
return;
}
// Find all images in the container
const items = await mediaViewerService.createItemsFromContainer(container, 'img:not(.note-icon)');
if (items.length === 0) {
toastService.showMessage('No images found in content');
return;
}
// Add note context to items
items.forEach(item => {
item.noteId = noteId;
});
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onContentViewerOpen(noteId),
onClose: () => this.onContentViewerClose(noteId),
onChange: (index) => this.onContentImageChange(index, items),
onImageError: (index, item, error) => this.onImageError(item, error)
};
const config: Partial<MediaViewerConfig> = {
getThumbBoundsFn: (index) => {
// Get thumbnail bounds for zoom animation
const item = items[index];
if (item.element) {
const rect = item.element.getBoundingClientRect();
return { x: rect.left, y: rect.top, w: rect.width };
}
return undefined;
}
};
mediaViewerService.open(items, startIndex, config, callbacks);
this.currentNoteId = noteId;
} catch (error) {
console.error('Failed to open content images:', error);
toastService.showError('Failed to open images');
}
}
/**
* Attach click handlers to images in a container with accessibility
*/
attachToContainer(container: HTMLElement, noteId: string): void {
try {
const images = container.querySelectorAll<HTMLImageElement>('img:not(.note-icon)');
images.forEach((img, index) => {
// Skip if already has handler
if (this.clickHandlers.has(img)) {
return;
}
const handler = () => {
this.openContentImages(noteId, container, index).catch(error => {
console.error('Failed to open content images:', error);
toastService.showError('Failed to open image viewer');
});
};
img.addEventListener('click', handler);
img.classList.add('media-viewer-trigger');
img.style.cursor = 'zoom-in';
// Add accessibility attributes
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('aria-label', img.alt || 'Click to view image in fullscreen');
// Add keyboard support for accessibility
const keyHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handler();
}
};
img.addEventListener('keydown', keyHandler);
// Store both handlers
this.clickHandlers.set(img, handler);
});
} catch (error) {
console.error('Failed to attach container handlers:', error);
}
}
/**
* Detach click handlers from a container
*/
detachFromContainer(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>('img.media-viewer-trigger');
images.forEach(img => {
const handler = this.clickHandlers.get(img);
if (handler) {
img.removeEventListener('click', handler);
img.classList.remove('media-viewer-trigger');
img.style.cursor = '';
this.clickHandlers.delete(img);
}
});
}
/**
* Called when viewer opens for a single image
*/
private onViewerOpen(noteId: string): void {
// Log for debugging purposes
console.debug('Media viewer opened for note:', noteId);
}
/**
* Called when viewer closes for a single image
*/
private onViewerClose(noteId: string): void {
this.currentNoteId = null;
console.debug('Media viewer closed for note:', noteId);
}
/**
* Called when gallery opens
*/
private onGalleryOpen(): void {
console.debug('Gallery opened with', this.galleryItems.length, 'items');
}
/**
* Called when gallery closes
*/
private onGalleryClose(): void {
this.isGalleryMode = false;
this.galleryItems = [];
console.debug('Gallery closed');
}
/**
* Called when gallery slide changes
*/
private onGalleryChange(index: number): void {
const item = this.galleryItems[index];
if (item && item.noteId) {
console.debug('Gallery slide changed to index:', index, 'noteId:', item.noteId);
}
}
/**
* Called when content viewer opens
*/
private onContentViewerOpen(noteId: string): void {
console.debug('Content viewer opened for note:', noteId);
}
/**
* Called when content viewer closes
*/
private onContentViewerClose(noteId: string): void {
this.currentNoteId = null;
console.debug('Content viewer closed for note:', noteId);
}
/**
* Called when content image changes
*/
private onContentImageChange(index: number, items: MediaItem[]): void {
console.debug('Content image changed to index:', index, 'of', items.length);
}
/**
* Handle image loading errors with graceful degradation
*/
private onImageError(item: MediaItem, error?: Error): void {
const errorMessage = `Failed to load image: ${item.title || 'Unknown'}`;
console.error(errorMessage, { src: item.src, error });
// Show user-friendly error message
toastService.showError(errorMessage);
// Log the error for debugging
console.debug('Image load error:', {
item,
error: error?.message || 'Unknown error'
});
}
/**
* Download current image
*/
async downloadCurrent(): Promise<void> {
if (!mediaViewerService.isOpen()) {
return;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
try {
const note = await froca.getNote(item.noteId);
if (note) {
const url = `api/notes/${note.noteId}/download`;
window.open(url);
}
} catch (error) {
console.error('Failed to download image:', error);
toastService.showError('Failed to download image');
}
}
}
/**
* Copy image reference to clipboard
*/
async copyImageReference(): Promise<void> {
if (!mediaViewerService.isOpen()) {
return;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
try {
const reference = `![](api/images/${item.noteId}/view)`;
await navigator.clipboard.writeText(reference);
toastService.showMessage('Image reference copied to clipboard');
} catch (error) {
console.error('Failed to copy image reference:', error);
toastService.showError('Failed to copy image reference');
}
}
}
/**
* Get metadata for current image with type safety
*/
async getCurrentMetadata(): Promise<{
noteId: string;
title: string;
mime?: string;
fileSize?: string;
width?: number;
height?: number;
dateCreated?: string;
dateModified?: string;
} | null> {
try {
if (!mediaViewerService.isOpen()) {
return null;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
const note = await froca.getNote(item.noteId);
if (note) {
const metadata = await note.getMetadata();
return {
noteId: note.noteId,
title: note.title || 'Untitled',
mime: note.mime,
fileSize: note.getAttribute('label', 'fileSize')?.value,
width: item.width,
height: item.height,
dateCreated: metadata.dateCreated,
dateModified: metadata.dateModified
};
}
}
} catch (error) {
console.error('Failed to get image metadata:', error);
}
return null;
}
/**
* Cleanup handlers and resources
*/
cleanup(): void {
try {
// Close viewer if open
mediaViewerService.close();
// Remove all click handlers
this.clickHandlers.forEach((handler, element) => {
element.removeEventListener('click', handler);
element.classList.remove('media-viewer-trigger');
element.style.cursor = '';
});
this.clickHandlers.clear();
// Remove keyboard handler with proper reference
if (this.boundKeyboardHandler) {
document.removeEventListener('keydown', this.boundKeyboardHandler);
this.boundKeyboardHandler = null;
}
// Clear references
this.currentNoteId = null;
this.galleryItems = [];
this.isGalleryMode = false;
} catch (error) {
console.error('Error during MediaViewerWidget cleanup:', error);
}
}
/**
* Handle note changes
*/
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): Promise<void> {
// Refresh viewer if current note was reloaded
if (this.currentNoteId && loadResults.isNoteReloaded(this.currentNoteId)) {
// Close and reopen with updated data
if (mediaViewerService.isOpen()) {
const index = mediaViewerService.getCurrentIndex();
mediaViewerService.close();
if (this.isGalleryMode) {
const noteIds = this.galleryItems.map(item => item.noteId).filter(Boolean) as string[];
await this.openGallery(noteIds, index);
} else {
await this.openImageNote(this.currentNoteId);
}
}
}
}
/**
* Apply theme changes
*/
themeChangedEvent(): void {
const isDarkTheme = document.body.classList.contains('theme-dark') ||
document.body.classList.contains('theme-next-dark');
mediaViewerService.applyTheme(isDarkTheme);
}
}
// Create global instance for easy access
const mediaViewerWidget = new MediaViewerWidget();
export default mediaViewerWidget;

View File

@@ -6,7 +6,6 @@ import contentRenderer from "../../services/content_renderer.js";
import utils from "../../services/utils.js"; import utils from "../../services/utils.js";
import options from "../../services/options.js"; import options from "../../services/options.js";
import attributes from "../../services/attributes.js"; import attributes from "../../services/attributes.js";
import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js";
export default class AbstractTextTypeWidget extends TypeWidget { export default class AbstractTextTypeWidget extends TypeWidget {
doRender() { doRender() {
@@ -36,29 +35,7 @@ export default class AbstractTextTypeWidget extends TypeWidget {
const parsedImage = await this.parseFromImage($img); const parsedImage = await this.parseFromImage($img);
if (parsedImage) { if (parsedImage) {
// Check if this is an attachment image and PhotoSwipe is available appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
if (parsedImage.viewScope?.attachmentId) {
// Instead of navigating to attachment detail, trigger PhotoSwipe
// Check if the image is already processed by PhotoSwipe
const imgElement = $img[0] as HTMLImageElement;
// Check if PhotoSwipe is integrated with this image using multiple reliable indicators
const hasPhotoSwipe = imgElement.classList.contains('photoswipe-enabled') ||
imgElement.hasAttribute('data-photoswipe') ||
imgElement.style.cursor === 'zoom-in';
if (hasPhotoSwipe) {
// Image has PhotoSwipe integration, trigger click to open lightbox
$img.trigger('click');
return;
}
// Otherwise, fall back to opening attachment detail (but with improved navigation)
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
} else {
// Regular note image, navigate normally
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
}
} else { } else {
window.open($img.prop("src"), "_blank"); window.open($img.prop("src"), "_blank");
} }

View File

@@ -4,8 +4,6 @@ import linkService from "../../services/link.js";
import utils from "../../services/utils.js"; import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js"; import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js"; import type { EventData } from "../../components/app_context.js";
import galleryManager from "../../services/gallery_manager.js";
import type { GalleryItem } from "../../services/gallery_manager.js";
const TPL = /*html*/` const TPL = /*html*/`
<div class="attachment-list note-detail-printable"> <div class="attachment-list note-detail-printable">
@@ -22,81 +20,17 @@ const TPL = /*html*/`
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
} }
.attachment-list .gallery-toolbar {
display: flex;
gap: 5px;
margin-bottom: 10px;
}
.attachment-list .gallery-toolbar button {
padding: 5px 10px;
font-size: 12px;
}
.attachment-list .image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.attachment-list .image-grid .image-thumbnail {
position: relative;
width: 100%;
padding-bottom: 100%; /* 1:1 aspect ratio */
overflow: hidden;
border-radius: 4px;
cursor: pointer;
background: var(--accented-background-color);
}
.attachment-list .image-grid .image-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover img {
transform: scale(1.05);
}
.attachment-list .image-grid .image-thumbnail .overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
color: white;
padding: 5px;
font-size: 11px;
opacity: 0;
transition: opacity 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover .overlay {
opacity: 1;
}
</style> </style>
<div class="links-wrapper"></div> <div class="links-wrapper"></div>
<div class="gallery-toolbar" style="display: none;"></div>
<div class="image-grid" style="display: none;"></div>
<div class="attachment-list-wrapper"></div> <div class="attachment-list-wrapper"></div>
</div>`; </div>`;
export default class AttachmentListTypeWidget extends TypeWidget { export default class AttachmentListTypeWidget extends TypeWidget {
$list!: JQuery<HTMLElement>; $list!: JQuery<HTMLElement>;
$linksWrapper!: JQuery<HTMLElement>; $linksWrapper!: JQuery<HTMLElement>;
$galleryToolbar!: JQuery<HTMLElement>;
$imageGrid!: JQuery<HTMLElement>;
renderedAttachmentIds!: Set<string>; renderedAttachmentIds!: Set<string>;
imageAttachments: GalleryItem[] = [];
otherAttachments: any[] = [];
static getType() { static getType() {
return "attachmentList"; return "attachmentList";
@@ -106,8 +40,6 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$list = this.$widget.find(".attachment-list-wrapper"); this.$list = this.$widget.find(".attachment-list-wrapper");
this.$linksWrapper = this.$widget.find(".links-wrapper"); this.$linksWrapper = this.$widget.find(".links-wrapper");
this.$galleryToolbar = this.$widget.find(".gallery-toolbar");
this.$imageGrid = this.$widget.find(".image-grid");
super.doRender(); super.doRender();
} }
@@ -143,12 +75,8 @@ export default class AttachmentListTypeWidget extends TypeWidget {
); );
this.$list.empty(); this.$list.empty();
this.$imageGrid.empty().hide();
this.$galleryToolbar.empty().hide();
this.children = []; this.children = [];
this.renderedAttachmentIds = new Set(); this.renderedAttachmentIds = new Set();
this.imageAttachments = [];
this.otherAttachments = [];
const attachments = await note.getAttachments(); const attachments = await note.getAttachments();
@@ -157,122 +85,17 @@ export default class AttachmentListTypeWidget extends TypeWidget {
return; return;
} }
// Separate image and non-image attachments
for (const attachment of attachments) { for (const attachment of attachments) {
if (attachment.role === 'image') {
const galleryItem: GalleryItem = {
src: `/api/attachments/${attachment.attachmentId}/image`,
alt: attachment.title,
title: attachment.title,
attachmentId: attachment.attachmentId,
noteId: attachment.ownerId,
index: this.imageAttachments.length
};
this.imageAttachments.push(galleryItem);
} else {
this.otherAttachments.push(attachment);
}
}
// If we have image attachments, show gallery view
if (this.imageAttachments.length > 0) {
this.setupGalleryView();
}
// Render non-image attachments in the traditional list
for (const attachment of this.otherAttachments) {
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false); const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
this.child(attachmentDetailWidget); this.child(attachmentDetailWidget);
this.renderedAttachmentIds.add(attachment.attachmentId); this.renderedAttachmentIds.add(attachment.attachmentId);
this.$list.append(attachmentDetailWidget.render()); this.$list.append(attachmentDetailWidget.render());
} }
} }
setupGalleryView() {
// Show gallery toolbar
this.$galleryToolbar.show();
// Add gallery action buttons
const $viewAllButton = $(`
<button class="btn btn-sm view-gallery-btn">
<span class="bx bx-images"></span>
View as Gallery (${this.imageAttachments.length} images)
</button>
`);
const $slideshowButton = $(`
<button class="btn btn-sm slideshow-btn">
<span class="bx bx-play-circle"></span>
Start Slideshow
</button>
`);
this.$galleryToolbar.append($viewAllButton, $slideshowButton);
// Handle gallery view button
$viewAllButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true,
loop: true
});
});
// Handle slideshow button
$slideshowButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: false,
autoPlay: true,
slideInterval: 4000,
showCounter: true,
loop: true
});
});
// Create image grid
this.$imageGrid.show();
this.imageAttachments.forEach((item, index) => {
const $thumbnail = $(`
<div class="image-thumbnail"
data-index="${index}"
role="button"
tabindex="0"
aria-label="View ${item.alt || item.title || 'image'} in gallery">
<img src="${item.src}"
alt="${item.alt || item.title || `Image ${index + 1}`}"
loading="lazy"
aria-describedby="thumb-desc-${index}">
<div class="overlay" id="thumb-desc-${index}">${item.title || ''}</div>
</div>
`);
// Add click handler
$thumbnail.on('click', () => {
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
});
// Add keyboard support for accessibility
$thumbnail.on('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
}
});
this.$imageGrid.append($thumbnail);
});
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed // updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId)); const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId));
@@ -281,16 +104,4 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.refresh(); this.refresh();
} }
} }
cleanup() {
// Clean up event handlers
if (this.$galleryToolbar) {
this.$galleryToolbar.find('button').off();
}
if (this.$imageGrid) {
this.$imageGrid.find('.image-thumbnail').off();
}
super.cleanup();
}
} }

View File

@@ -14,7 +14,6 @@ import type FNote from "../../entities/fnote.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5"; import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css"; import "@triliumnext/ckeditor5/index.css";
import { updateTemplateCache } from "./ckeditor/snippets.js"; import { updateTemplateCache } from "./ckeditor/snippets.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/` const TPL = /*html*/`
<div class="note-detail-editable-text note-detail-printable"> <div class="note-detail-editable-text note-detail-printable">
@@ -163,19 +162,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
isClassicEditor isClassicEditor
}; };
const editor = await buildEditor(this.$editor[0], isClassicEditor, opts); const editor = await buildEditor(this.$editor[0], isClassicEditor, opts);
// Setup PhotoSwipe integration for images in the editor
setTimeout(() => {
const editorElement = this.$editor[0];
if (editorElement) {
ckeditorPhotoSwipe.setupContainer(editorElement, {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.cke_widget_element, .ck-widget'
});
}
}, 100);
const notificationsPlugin = editor.plugins.get("Notification"); const notificationsPlugin = editor.plugins.get("Notification");
notificationsPlugin.on("show:warning", (evt, data) => { notificationsPlugin.on("show:warning", (evt, data) => {
@@ -305,25 +291,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
} }
cleanup() { cleanup() {
// Cleanup PhotoSwipe integration
if (this.$editor?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]);
}
if (this.watchdog?.editor) { if (this.watchdog?.editor) {
this.spacedUpdate.allowUpdateWithoutChange(() => { this.spacedUpdate.allowUpdateWithoutChange(() => {
this.watchdog.editor?.setData(""); this.watchdog.editor?.setData("");
}); });
} }
// Destroy the watchdog to clean up all CKEditor resources
if (this.watchdog) {
this.watchdog.destroy().catch((error: any) => {
console.error('Error destroying CKEditor watchdog:', error);
});
}
super.cleanup();
} }
insertDateTimeToTextCommand() { insertDateTimeToTextCommand() {

View File

@@ -1,5 +1,5 @@
import openService from "../../services/open.js"; import openService from "../../services/open.js";
import { ImageViewerBase } from "./image_viewer_base.js"; import TypeWidget from "./type_widget.js";
import { t } from "../../services/i18n.js"; import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js"; import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js"; import type FNote from "../../entities/fnote.js";
@@ -23,8 +23,7 @@ const TPL = /*html*/`
} }
.note-detail.full-height .note-detail-file[data-preview-type="pdf"], .note-detail.full-height .note-detail-file[data-preview-type="pdf"],
.note-detail.full-height .note-detail-file[data-preview-type="video"], .note-detail.full-height .note-detail-file[data-preview-type="video"] {
.note-detail.full-height .note-detail-file[data-preview-type="image"] {
overflow: hidden; overflow: hidden;
} }
@@ -40,133 +39,6 @@ const TPL = /*html*/`
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.image-file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
position: relative;
overflow: hidden;
}
.image-file-view {
max-width: 100%;
max-height: 90%;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.image-file-view:hover {
opacity: 0.95;
}
.image-file-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
z-index: 10;
}
.image-file-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-file-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-file-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-file-control-btn i {
font-size: 20px;
color: #333;
}
.image-file-info {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-file-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.image-file-info {
font-size: 11px;
padding: 6px 10px;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-file-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.image-file-view,
.image-file-control-btn {
transition: none;
}
}
</style> </style>
<div class="file-preview-too-big alert alert-info hidden-ext"> <div class="file-preview-too-big alert alert-info hidden-ext">
@@ -184,66 +56,21 @@ const TPL = /*html*/`
<video class="video-preview" controls></video> <video class="video-preview" controls></video>
<audio class="audio-preview" controls></audio> <audio class="audio-preview" controls></audio>
<div class="image-file-preview" style="display: none;">
<div class="image-file-info">
<span class="image-dimensions"></span>
</div>
<img class="image-file-view" />
<div class="image-file-controls">
<button class="image-file-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn fullscreen" type="button" aria-label="Open in Lightbox" title="Open in Lightbox (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn download" type="button" aria-label="Download" title="Download File">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`; </div>`;
export default class FileTypeWidget extends ImageViewerBase { export default class FileTypeWidget extends TypeWidget {
private $previewContent!: JQuery<HTMLElement>; private $previewContent!: JQuery<HTMLElement>;
private $previewNotAvailable!: JQuery<HTMLElement>; private $previewNotAvailable!: JQuery<HTMLElement>;
private $previewTooBig!: JQuery<HTMLElement>; private $previewTooBig!: JQuery<HTMLElement>;
private $pdfPreview!: JQuery<HTMLElement>; private $pdfPreview!: JQuery<HTMLElement>;
private $videoPreview!: JQuery<HTMLElement>; private $videoPreview!: JQuery<HTMLElement>;
private $audioPreview!: JQuery<HTMLElement>; private $audioPreview!: JQuery<HTMLElement>;
private $imageFilePreview!: JQuery<HTMLElement>;
private $imageFileView!: JQuery<HTMLElement>;
private $imageDimensions!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
private currentPreviewType?: string;
static getType() { static getType() {
return "file"; return "file";
} }
constructor() {
super();
// Apply custom configuration for file viewer
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$previewContent = this.$widget.find(".file-preview-content"); this.$previewContent = this.$widget.find(".file-preview-content");
@@ -252,204 +79,60 @@ export default class FileTypeWidget extends ImageViewerBase {
this.$pdfPreview = this.$widget.find(".pdf-preview"); this.$pdfPreview = this.$widget.find(".pdf-preview");
this.$videoPreview = this.$widget.find(".video-preview"); this.$videoPreview = this.$widget.find(".video-preview");
this.$audioPreview = this.$widget.find(".audio-preview"); this.$audioPreview = this.$widget.find(".audio-preview");
this.$imageFilePreview = this.$widget.find(".image-file-preview");
this.$imageFileView = this.$widget.find(".image-file-view");
this.$imageDimensions = this.$widget.find(".image-dimensions");
// Image controls
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
// Set image wrapper and view for base class
this.$imageWrapper = this.$imageFilePreview;
this.$imageView = this.$imageFileView;
this.setupImageControls();
super.doRender(); super.doRender();
} }
private setupImageControls(): void {
// Image click to open lightbox
this.$imageFileView?.on("click", (e) => {
e.preventDefault();
this.openImageInLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", () => this.openImageInLightbox());
this.$downloadBtn?.on("click", () => this.downloadFile());
// Mouse wheel zoom with focus check
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if image preview is visible and has focus
if (!this.$imageFilePreview?.is(':visible') || !this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageFilePreview?.on("wheel", this.wheelHandler);
}
async doRefresh(note: FNote) { async doRefresh(note: FNote) {
this.$widget?.show(); this.$widget.show();
const blob = await this.note?.getBlob(); const blob = await this.note?.getBlob();
// Hide all preview types this.$previewContent.empty().hide();
this.$previewContent?.empty().hide(); this.$pdfPreview.attr("src", "").empty().hide();
this.$pdfPreview?.attr("src", "").empty().hide(); this.$previewNotAvailable.hide();
this.$previewNotAvailable?.hide(); this.$previewTooBig.addClass("hidden-ext");
this.$previewTooBig?.addClass("hidden-ext"); this.$videoPreview.hide();
this.$videoPreview?.hide(); this.$audioPreview.hide();
this.$audioPreview?.hide();
this.$imageFilePreview?.hide();
let previewType: string; let previewType: string;
// Check if this is an image file if (blob?.content) {
if (note.mime.startsWith("image/")) { this.$previewContent.show().scrollTop(0);
this.$imageFilePreview?.show();
const src = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
// Reset zoom for new image
this.resetZoom();
// Setup pan, keyboard navigation, and other features
this.setupPanFunctionality();
this.setupKeyboardNavigation();
this.setupDoubleClickReset();
this.setupContextMenu();
this.addAccessibilityLabels();
// Load image with loading state and error handling
try {
await this.setupImage(src, this.$imageFileView!);
await this.loadImageDimensions(src);
} catch (error) {
console.error("Failed to load image file:", error);
}
previewType = "image";
} else if (blob?.content) {
this.$previewContent?.show().scrollTop(0);
const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS); const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS);
if (trimmedContent.length !== blob.content.length) { if (trimmedContent.length !== blob.content.length) {
this.$previewTooBig?.removeClass("hidden-ext"); this.$previewTooBig.removeClass("hidden-ext");
} }
this.$previewContent?.text(trimmedContent); this.$previewContent.text(trimmedContent);
previewType = "text"; previewType = "text";
} else if (note.mime === "application/pdf") { } else if (note.mime === "application/pdf") {
this.$pdfPreview?.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`)); this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));
previewType = "pdf"; previewType = "pdf";
} else if (note.mime.startsWith("video/")) { } else if (note.mime.startsWith("video/")) {
this.$videoPreview this.$videoPreview
?.show() .show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "") .attr("type", this.note?.mime ?? "")
.css("width", this.$widget?.width() ?? 0); .css("width", this.$widget.width() ?? 0);
previewType = "video"; previewType = "video";
} else if (note.mime.startsWith("audio/")) { } else if (note.mime.startsWith("audio/")) {
this.$audioPreview this.$audioPreview
?.show() .show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "") .attr("type", this.note?.mime ?? "")
.css("width", this.$widget?.width() ?? 0); .css("width", this.$widget.width() ?? 0);
previewType = "audio"; previewType = "audio";
} else { } else {
this.$previewNotAvailable?.show(); this.$previewNotAvailable.show();
previewType = "not-available"; previewType = "not-available";
} }
this.currentPreviewType = previewType; this.$widget.attr("data-preview-type", previewType ?? "");
this.$widget?.attr("data-preview-type", previewType ?? "");
}
private async loadImageDimensions(src: string): Promise<void> {
try {
// Use a new Image object to get dimensions
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => {
this.$imageDimensions?.text(`${img.width} × ${img.height}px`);
resolve();
};
img.onerror = () => {
this.$imageDimensions?.text("Image");
reject(new Error("Failed to load image dimensions"));
};
img.src = src;
});
} catch (error) {
console.warn("Failed to get image dimensions:", error);
this.$imageDimensions?.text("Image");
}
}
private openImageInLightbox(): void {
if (!this.note || !this.$imageFileView?.length) return;
const src = this.$imageFileView.attr("src") || this.$imageFileView.prop("src");
if (!src) return;
this.openInLightbox(
src,
this.note.title || "Image File",
this.noteId,
this.$imageFileView.get(0)
);
}
private downloadFile(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
link.download = this.note.title || 'file';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download file:", error);
alert("Failed to download file. Please try again.");
}
} }
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) { if (loadResults.isNoteReloaded(this.noteId)) {
await this.refresh(); this.refresh();
} }
} }
}
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageFilePreview?.length) {
this.$imageFilePreview.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
}

View File

@@ -1,8 +1,10 @@
import utils from "../../services/utils.js"; import utils from "../../services/utils.js";
import { ImageViewerBase } from "./image_viewer_base.js"; import TypeWidget from "./type_widget.js";
import imageContextMenuService from "../../menus/image_context_menu.js";
import imageService from "../../services/image.js"; import imageService from "../../services/image.js";
import type FNote from "../../entities/fnote.js"; import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js"; import type { EventData } from "../../components/app_context.js";
import WheelZoom from 'vanilla-js-wheel-zoom';
const TPL = /*html*/` const TPL = /*html*/`
<div class="note-detail-image note-detail-printable"> <div class="note-detail-image note-detail-printable">
@@ -13,7 +15,6 @@ const TPL = /*html*/`
.note-detail-image { .note-detail-image {
height: 100%; height: 100%;
position: relative;
} }
.note-detail-image-wrapper { .note-detail-image-wrapper {
@@ -27,314 +28,53 @@ const TPL = /*html*/`
.note-detail-image-view { .note-detail-image-view {
display: block; display: block;
max-width: 100%;
max-height: 100%;
width: auto; width: auto;
height: auto; height: auto;
align-self: center; align-self: center;
flex-shrink: 0; flex-shrink: 0;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.note-detail-image-view:hover {
opacity: 0.95;
}
.image-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 10;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
}
.image-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-control-btn i {
font-size: 20px;
color: #333;
}
/* Keyboard hints overlay */
.keyboard-hints {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 10;
}
.note-detail-image:hover .keyboard-hints {
opacity: 0.8;
}
.keyboard-hints .hint {
margin: 2px 0;
}
.keyboard-hints .key {
background: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
font-family: monospace;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.keyboard-hints {
display: none;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.note-detail-image-view,
.image-control-btn {
transition: none;
}
} }
</style> </style>
<div class="note-detail-image-wrapper"> <div class="note-detail-image-wrapper">
<img class="note-detail-image-view" /> <img class="note-detail-image-view" />
</div> </div>
<div class="image-controls">
<button class="image-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-control-btn fullscreen" type="button" aria-label="Fullscreen" title="Fullscreen (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-control-btn download" type="button" aria-label="Download" title="Download Image">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
<div class="keyboard-hints" aria-hidden="true">
<div class="hint"><span class="key">Click</span> Open lightbox</div>
<div class="hint"><span class="key">Double-click</span> Reset zoom</div>
<div class="hint"><span class="key">Scroll</span> Zoom</div>
<div class="hint"><span class="key">+/-</span> Zoom in/out</div>
<div class="hint"><span class="key">0</span> Reset zoom</div>
<div class="hint"><span class="key">ESC</span> Close lightbox</div>
<div class="hint"><span class="key">Arrow keys</span> Pan (when zoomed)</div>
</div>
</div>`; </div>`;
class ImageTypeWidget extends ImageViewerBase { class ImageTypeWidget extends TypeWidget {
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>; private $imageWrapper!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>; private $imageView!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
static getType() { static getType() {
return "image"; return "image";
} }
constructor() {
super();
// Apply custom configuration if needed
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper"); this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper");
this.$imageView = this.$widget.find(".note-detail-image-view"); this.$imageView = this.$widget.find(".note-detail-image-view").attr("id", `image-view-${utils.randomString(10)}`);
// Generate unique ID for image element
const imageId = `image-view-${utils.randomString(10)}`;
this.$imageView.attr("id", imageId);
// Get control buttons
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
this.setupEventHandlers(); const initZoom = async () => {
this.setupPanFunctionality(); const element = document.querySelector(`#${this.$imageView.attr("id")}`);
this.setupKeyboardNavigation(); if (element) {
this.setupDoubleClickReset(); WheelZoom.create(`#${this.$imageView.attr("id")}`, {
this.setupContextMenu(); maxScale: 50,
this.addAccessibilityLabels(); speed: 1.3,
zoomOnClick: false
});
} else {
requestAnimationFrame(initZoom);
}
};
initZoom();
imageContextMenuService.setupContextMenu(this.$imageView);
super.doRender(); super.doRender();
} }
private setupEventHandlers(): void {
// Image click to open lightbox
this.$imageView?.on("click", async (e) => {
e.preventDefault();
await this.handleOpenLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", async () => await this.handleOpenLightbox());
this.$downloadBtn?.on("click", () => this.downloadImage());
// Mouse wheel zoom with debouncing
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if widget has focus
if (!this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageWrapper?.on("wheel", this.wheelHandler);
}
private async handleOpenLightbox(): Promise<void> {
if (!this.$imageView?.length) return;
const src = this.$imageView.attr('src') || this.$imageView.prop('src');
if (!src) return;
await this.openInLightbox(
src,
this.note?.title,
this.noteId,
this.$imageView.get(0)
);
}
async doRefresh(note: FNote) { async doRefresh(note: FNote) {
const src = utils.createImageSrcUrl(note); this.$imageView.prop("src", utils.createImageSrcUrl(note));
// Reset zoom when image changes
this.resetZoom();
// Refresh gallery items when note changes
await this.refreshGalleryItems();
// Setup image with loading state and error handling
try {
await this.setupImage(src, this.$imageView!);
} catch (error) {
console.error("Failed to load image:", error);
// Error message is already shown by setupImage
}
}
private downloadImage(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = utils.createImageSrcUrl(this.note);
link.download = this.note.title || 'image';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download image:", error);
alert("Failed to download image. Please try again.");
}
} }
copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) { copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) {
@@ -342,26 +82,14 @@ class ImageTypeWidget extends ImageViewerBase {
return; return;
} }
if (this.$imageWrapper?.length) { imageService.copyImageReferenceToClipboard(this.$imageWrapper);
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
}
} }
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) { if (loadResults.isNoteReloaded(this.noteId)) {
await this.refresh(); this.refresh();
} }
} }
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageWrapper?.length) {
this.$imageWrapper.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
} }
export default ImageTypeWidget; export default ImageTypeWidget;

View File

@@ -1,352 +0,0 @@
import { ImageViewerBase } from './image_viewer_base.js';
import mediaViewer from '../../services/media_viewer.js';
// Mock mediaViewer
jest.mock('../../services/media_viewer.js', () => ({
default: {
isOpen: jest.fn().mockReturnValue(false),
close: jest.fn(),
openSingle: jest.fn(),
getImageDimensions: jest.fn().mockResolvedValue({ width: 1920, height: 1080 })
}
}));
// Create a concrete test class
class TestImageViewer extends ImageViewerBase {
static getType() {
return 'test';
}
doRender() {
this.$widget = $('<div class="test-widget" tabindex="0"></div>');
this.$imageWrapper = $('<div class="image-wrapper"></div>');
this.$imageView = $('<img class="image-view" />');
this.$widget.append(this.$imageWrapper.append(this.$imageView));
super.doRender();
}
async doRefresh() {
// Test implementation
}
}
describe('ImageViewerBase', () => {
let widget: TestImageViewer;
let $container: JQuery<HTMLElement>;
beforeEach(() => {
// Setup DOM container
$container = $('<div id="test-container"></div>');
$('body').append($container);
widget = new TestImageViewer();
widget.doRender();
$container.append(widget.$widget!);
});
afterEach(() => {
widget.cleanup();
$container.remove();
jest.clearAllMocks();
});
describe('PhotoSwipe Verification', () => {
it('should verify PhotoSwipe availability on initialization', () => {
expect(widget['isPhotoSwipeAvailable']).toBe(true);
});
it('should handle PhotoSwipe not being available gracefully', () => {
const originalMediaViewer = mediaViewer;
// @ts-ignore - Temporarily set to undefined for testing
window.mediaViewer = undefined;
const newWidget = new TestImageViewer();
expect(newWidget['isPhotoSwipeAvailable']).toBe(false);
// @ts-ignore - Restore
window.mediaViewer = originalMediaViewer;
});
});
describe('Configuration', () => {
it('should have default configuration values', () => {
expect(widget['config'].minZoom).toBe(0.5);
expect(widget['config'].maxZoom).toBe(5);
expect(widget['config'].zoomStep).toBe(0.25);
expect(widget['config'].debounceDelay).toBe(16);
expect(widget['config'].touchTargetSize).toBe(44);
});
it('should allow configuration overrides', () => {
widget['applyConfig']({
minZoom: 0.2,
maxZoom: 10,
zoomStep: 0.5
});
expect(widget['config'].minZoom).toBe(0.2);
expect(widget['config'].maxZoom).toBe(10);
expect(widget['config'].zoomStep).toBe(0.5);
expect(widget['config'].debounceDelay).toBe(16); // Unchanged
});
});
describe('Loading States', () => {
it('should show loading indicator when loading image', () => {
widget['showLoadingIndicator']();
expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(1);
});
it('should hide loading indicator after loading', () => {
widget['showLoadingIndicator']();
widget['hideLoadingIndicator']();
expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(0);
});
it('should handle image load errors gracefully', async () => {
const mockImage = {
onload: null as any,
onerror: null as any,
src: ''
};
// @ts-ignore
global.Image = jest.fn(() => mockImage);
const setupPromise = widget['setupImage']('test.jpg', widget.$imageView!);
// Trigger error
mockImage.onerror(new Error('Failed to load'));
await expect(setupPromise).rejects.toThrow('Failed to load image');
expect(widget.$imageWrapper?.find('.alert-danger').length).toBe(1);
});
});
describe('Zoom Functionality', () => {
it('should zoom in correctly', () => {
const initialZoom = widget['currentZoom'];
widget['zoomIn']();
// Wait for debounce
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBeGreaterThan(initialZoom);
});
it('should zoom out correctly', () => {
widget['currentZoom'] = 2;
widget['zoomOut']();
// Wait for debounce
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBeLessThan(2);
});
it('should respect zoom limits', () => {
// Test max zoom
widget['currentZoom'] = widget['config'].maxZoom;
widget['zoomIn']();
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBe(widget['config'].maxZoom);
// Test min zoom
widget['currentZoom'] = widget['config'].minZoom;
widget['zoomOut']();
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBe(widget['config'].minZoom);
});
it('should reset zoom to 100%', () => {
widget['currentZoom'] = 3;
widget['resetZoom']();
expect(widget['currentZoom']).toBe(1);
});
it('should show zoom indicator when zooming', () => {
widget['updateZoomIndicator']();
expect(widget.$widget?.find('.zoom-indicator').length).toBe(1);
expect(widget.$widget?.find('.zoom-indicator').text()).toBe('100%');
});
});
describe('Keyboard Navigation', () => {
it('should only handle keyboard events when widget has focus', () => {
const preventDefaultSpy = jest.fn();
const stopPropagationSpy = jest.fn();
// Simulate widget not having focus
widget.$widget?.blur();
const event = $.Event('keydown', {
key: '+',
preventDefault: preventDefaultSpy,
stopPropagation: stopPropagationSpy
});
widget.$widget?.trigger(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(stopPropagationSpy).not.toHaveBeenCalled();
});
it('should handle zoom keyboard shortcuts when focused', () => {
// Focus the widget
widget.$widget?.focus();
jest.spyOn(widget.$widget!, 'is').mockImplementation((selector) => {
if (selector === ':focus-within') return true;
return false;
});
const zoomInSpy = jest.spyOn(widget as any, 'zoomIn');
const event = $.Event('keydown', { key: '+' });
widget.$widget?.trigger(event);
expect(zoomInSpy).toHaveBeenCalled();
});
});
describe('Pan Functionality', () => {
it('should setup pan event handlers', () => {
widget['setupPanFunctionality']();
expect(widget['boundHandlers'].size).toBeGreaterThan(0);
});
it('should only allow panning when zoomed in', () => {
widget['currentZoom'] = 1; // Not zoomed
const mouseDownEvent = $.Event('mousedown', { pageX: 100, pageY: 100 });
widget.$imageWrapper?.trigger(mouseDownEvent);
expect(widget['isDragging']).toBe(false);
// Now zoom in and try again
widget['currentZoom'] = 2;
widget.$imageWrapper?.trigger(mouseDownEvent);
expect(widget['isDragging']).toBe(true);
});
});
describe('Accessibility', () => {
it('should add ARIA labels to buttons', () => {
const $button = $('<button class="zoom-in"></button>');
widget.$widget?.append($button);
widget['addAccessibilityLabels']();
expect($button.attr('aria-label')).toBe('Zoom in');
expect($button.attr('role')).toBe('button');
});
it('should make widget focusable with proper ARIA attributes', () => {
widget['setupKeyboardNavigation']();
expect(widget.$widget?.attr('tabindex')).toBe('0');
expect(widget.$widget?.attr('role')).toBe('application');
expect(widget.$widget?.attr('aria-label')).toBeTruthy();
});
});
describe('Lightbox Integration', () => {
it('should open lightbox when PhotoSwipe is available', () => {
const openSingleSpy = jest.spyOn(mediaViewer, 'openSingle');
widget['openInLightbox']('test.jpg', 'Test Image', 'note123');
expect(openSingleSpy).toHaveBeenCalledWith(
expect.objectContaining({
src: 'test.jpg',
alt: 'Test Image',
title: 'Test Image',
noteId: 'note123'
}),
expect.any(Object),
expect.any(Object)
);
});
it('should fallback to opening in new tab when PhotoSwipe is not available', () => {
widget['isPhotoSwipeAvailable'] = false;
const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation();
widget['openInLightbox']('test.jpg', 'Test Image');
expect(windowOpenSpy).toHaveBeenCalledWith('test.jpg', '_blank');
expect(mediaViewer.openSingle).not.toHaveBeenCalled();
});
});
describe('Memory Leak Prevention', () => {
it('should cleanup all event handlers on cleanup', () => {
widget['setupPanFunctionality']();
widget['setupKeyboardNavigation']();
const initialHandlerCount = widget['boundHandlers'].size;
expect(initialHandlerCount).toBeGreaterThan(0);
widget.cleanup();
expect(widget['boundHandlers'].size).toBe(0);
});
it('should cancel animation frames on cleanup', () => {
const cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame');
widget['rafId'] = 123;
widget.cleanup();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(123);
expect(widget['rafId']).toBeNull();
});
it('should clear timers on cleanup', () => {
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
widget['zoomDebounceTimer'] = 456;
widget.cleanup();
expect(clearTimeoutSpy).toHaveBeenCalledWith(456);
expect(widget['zoomDebounceTimer']).toBeNull();
});
it('should close lightbox if open on cleanup', () => {
jest.spyOn(mediaViewer, 'isOpen').mockReturnValue(true);
const closeSpy = jest.spyOn(mediaViewer, 'close');
widget.cleanup();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('Double-click Reset', () => {
it('should reset zoom on double-click', () => {
widget['currentZoom'] = 3;
widget['setupDoubleClickReset']();
const dblClickEvent = $.Event('dblclick');
widget.$imageView?.trigger(dblClickEvent);
expect(widget['currentZoom']).toBe(1);
});
});
describe('Error Handling', () => {
it('should show error message to user on failure', () => {
widget['showErrorMessage']('Test error message');
const $error = widget.$imageWrapper?.find('.alert-danger');
expect($error?.length).toBe(1);
expect($error?.text()).toBe('Test error message');
});
it('should handle null/undefined elements safely', () => {
widget.$imageView = undefined;
// Should not throw
expect(() => widget['setupImage']('test.jpg', widget.$imageView!)).not.toThrow();
});
});
});

View File

@@ -1,787 +0,0 @@
/**
* Base class for widgets that display images with zoom, pan, and lightbox functionality.
* Provides shared image viewing logic to avoid code duplication.
*/
import TypeWidget from "./type_widget.js";
import mediaViewer from "../../services/media_viewer.js";
import type { MediaItem, MediaViewerCallbacks } from "../../services/media_viewer.js";
import imageContextMenuService from "../../menus/image_context_menu.js";
import galleryManager from "../../services/gallery_manager.js";
import type { GalleryItem, GalleryConfig } from "../../services/gallery_manager.js";
export interface ImageViewerConfig {
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
debounceDelay?: number;
touchTargetSize?: number;
}
export abstract class ImageViewerBase extends TypeWidget {
// Configuration
protected config: Required<ImageViewerConfig> = {
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16, // ~60fps
touchTargetSize: 44 // WCAG recommended minimum
};
// State
protected currentZoom: number = 1;
protected isDragging: boolean = false;
protected startX: number = 0;
protected startY: number = 0;
protected scrollLeft: number = 0;
protected scrollTop: number = 0;
protected isPhotoSwipeAvailable: boolean = false;
protected isLoadingImage: boolean = false;
protected galleryItems: GalleryItem[] = [];
protected currentImageIndex: number = 0;
// Elements
protected $imageWrapper?: JQuery<HTMLElement>;
protected $imageView?: JQuery<HTMLElement>;
protected $zoomIndicator?: JQuery<HTMLElement>;
protected $loadingIndicator?: JQuery<HTMLElement>;
// Event handler references for cleanup
private boundHandlers: Map<string, Function> = new Map();
private rafId: number | null = null;
private zoomDebounceTimer: number | null = null;
constructor() {
super();
this.verifyPhotoSwipe();
}
/**
* Verify PhotoSwipe is available
*/
protected verifyPhotoSwipe(): void {
try {
// Check if PhotoSwipe is loaded
if (typeof mediaViewer !== 'undefined' && mediaViewer) {
this.isPhotoSwipeAvailable = true;
} else {
console.warn("PhotoSwipe/mediaViewer not available, lightbox features disabled");
this.isPhotoSwipeAvailable = false;
}
} catch (error) {
console.error("Error checking PhotoSwipe availability:", error);
this.isPhotoSwipeAvailable = false;
}
}
/**
* Apply configuration overrides
*/
protected applyConfig(overrides?: ImageViewerConfig): void {
if (overrides) {
this.config = { ...this.config, ...overrides };
}
}
/**
* Show loading indicator
*/
protected showLoadingIndicator(): void {
if (!this.$loadingIndicator) {
this.$loadingIndicator = $('<div class="image-loading-indicator">')
.html('<div class="spinner-border spinner-border-sm" role="status"><span class="sr-only">Loading...</span></div>')
.css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 100
});
}
this.$imageWrapper?.append(this.$loadingIndicator);
this.isLoadingImage = true;
}
/**
* Hide loading indicator
*/
protected hideLoadingIndicator(): void {
this.$loadingIndicator?.remove();
this.isLoadingImage = false;
}
/**
* Setup image with loading state and error handling
*/
protected async setupImage(src: string, $image: JQuery<HTMLElement>): Promise<void> {
if (!$image || !$image.length) {
console.error("Image element not provided");
return;
}
this.showLoadingIndicator();
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this.hideLoadingIndicator();
$image.attr('src', src);
// Preload dimensions for PhotoSwipe if available
if (this.isPhotoSwipeAvailable) {
this.preloadImageDimensions(src).catch(console.warn);
}
resolve();
};
img.onerror = (error) => {
this.hideLoadingIndicator();
console.error("Failed to load image:", error);
this.showErrorMessage("Failed to load image");
reject(new Error("Failed to load image"));
};
img.src = src;
});
}
/**
* Show error message to user
*/
protected showErrorMessage(message: string): void {
const $error = $('<div class="alert alert-danger">')
.text(message)
.css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '80%'
});
this.$imageWrapper?.empty().append($error);
}
/**
* Preload image dimensions for PhotoSwipe
*/
protected async preloadImageDimensions(src: string): Promise<void> {
if (!this.isPhotoSwipeAvailable) return;
try {
await mediaViewer.getImageDimensions(src);
} catch (error) {
console.warn("Failed to preload image dimensions:", error);
}
}
/**
* Detect and collect gallery items from the current context
*/
protected async detectGalleryItems(): Promise<GalleryItem[]> {
// Default implementation - can be overridden by subclasses
if (this.note && this.note.type === 'text') {
// For text notes, scan for all images
return await galleryManager.createGalleryFromNote(this.note);
}
// For single image notes, return just the current image
const src = this.$imageView?.attr('src') || this.$imageView?.prop('src');
if (src) {
return [{
src: src,
alt: this.note?.title || 'Image',
title: this.note?.title,
noteId: this.noteId,
index: 0
}];
}
return [];
}
/**
* Open image in lightbox with gallery support
*/
protected async openInLightbox(src: string, title?: string, noteId?: string, element?: HTMLElement): Promise<void> {
if (!this.isPhotoSwipeAvailable) {
console.warn("PhotoSwipe not available, cannot open lightbox");
// Fallback: open image in new tab
window.open(src, '_blank');
return;
}
if (!src) {
console.error("No image source provided for lightbox");
return;
}
try {
// Detect if we should open as a gallery
if (this.galleryItems.length === 0) {
this.galleryItems = await this.detectGalleryItems();
}
// Find the index of the current image in the gallery
let startIndex = 0;
if (this.galleryItems.length > 1) {
startIndex = this.galleryItems.findIndex(item => item.src === src);
if (startIndex === -1) startIndex = 0;
}
// Open as gallery if multiple items, otherwise single image
if (this.galleryItems.length > 1) {
// Open gallery with all images
const galleryConfig: GalleryConfig = {
showThumbnails: true,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: true,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log("Gallery opened with", this.galleryItems.length, "images");
},
onClose: () => {
console.log("Gallery closed");
// Restore focus to the image element
element?.focus();
},
onChange: (index) => {
console.log("Gallery slide changed to:", index);
this.currentImageIndex = index;
},
onImageLoad: (index, mediaItem) => {
console.log("Gallery image loaded:", mediaItem.title);
},
onImageError: (index, mediaItem, error) => {
console.error("Failed to load gallery image:", error);
}
};
galleryManager.openGallery(this.galleryItems, startIndex, galleryConfig, callbacks);
} else {
// Open single image
const item: MediaItem = {
src: src,
alt: title || "Image",
title: title,
noteId: noteId,
element: element
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log("Image lightbox opened");
},
onClose: () => {
console.log("Image lightbox closed");
// Restore focus to the image element
element?.focus();
},
onImageLoad: (index, mediaItem) => {
console.log("Image loaded in lightbox:", mediaItem.title);
},
onImageError: (index, mediaItem, error) => {
console.error("Failed to load image in lightbox:", error);
}
};
// Open with enhanced configuration
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
arrowKeys: false,
loop: false,
maxSpreadZoom: 10,
getThumbBoundsFn: (index: number) => {
// Get position of thumbnail for zoom animation
if (element) {
const rect = element.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
return undefined;
}
}, callbacks);
}
} catch (error) {
console.error("Failed to open lightbox:", error);
// Fallback: open image in new tab
window.open(src, '_blank');
}
}
/**
* Zoom in with debouncing
*/
protected zoomIn(): void {
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
}
this.zoomDebounceTimer = window.setTimeout(() => {
this.currentZoom = Math.min(this.currentZoom + this.config.zoomStep, this.config.maxZoom);
this.applyZoom();
}, this.config.debounceDelay);
}
/**
* Zoom out with debouncing
*/
protected zoomOut(): void {
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
}
this.zoomDebounceTimer = window.setTimeout(() => {
this.currentZoom = Math.max(this.currentZoom - this.config.zoomStep, this.config.minZoom);
this.applyZoom();
}, this.config.debounceDelay);
}
/**
* Reset zoom to 100%
*/
protected resetZoom(): void {
this.currentZoom = 1;
this.applyZoom();
if (this.$imageWrapper?.length) {
this.$imageWrapper.scrollLeft(0).scrollTop(0);
}
}
/**
* Apply zoom with requestAnimationFrame for smooth performance
*/
protected applyZoom(): void {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(() => {
if (!this.$imageView?.length) return;
this.$imageView.css({
transform: `scale(${this.currentZoom})`,
transformOrigin: 'center center'
});
// Update zoom indicator
this.updateZoomIndicator();
// Update button states
this.updateZoomButtonStates();
// Update cursor based on zoom level
if (this.currentZoom > 1) {
this.$imageView.css('cursor', 'move');
} else {
this.$imageView.css('cursor', 'zoom-in');
}
});
}
/**
* Update zoom percentage indicator
*/
protected updateZoomIndicator(): void {
const percentage = Math.round(this.currentZoom * 100);
if (!this.$zoomIndicator) {
this.$zoomIndicator = $('<div class="zoom-indicator">')
.css({
position: 'absolute',
bottom: '60px',
right: '20px',
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 10
})
.attr('aria-live', 'polite')
.attr('aria-label', 'Zoom level');
this.$widget?.append(this.$zoomIndicator);
}
this.$zoomIndicator.text(`${percentage}%`);
// Hide indicator after 2 seconds
if (this.$zoomIndicator.data('hideTimer')) {
clearTimeout(this.$zoomIndicator.data('hideTimer'));
}
this.$zoomIndicator.show();
const hideTimer = setTimeout(() => {
this.$zoomIndicator?.fadeOut();
}, 2000);
this.$zoomIndicator.data('hideTimer', hideTimer);
}
/**
* Update zoom button states
*/
protected updateZoomButtonStates(): void {
const $zoomInBtn = this.$widget?.find('.zoom-in, .image-control-btn.zoom-in');
const $zoomOutBtn = this.$widget?.find('.zoom-out, .image-control-btn.zoom-out');
if ($zoomInBtn?.length) {
$zoomInBtn.prop('disabled', this.currentZoom >= this.config.maxZoom);
$zoomInBtn.attr('aria-disabled', (this.currentZoom >= this.config.maxZoom).toString());
}
if ($zoomOutBtn?.length) {
$zoomOutBtn.prop('disabled', this.currentZoom <= this.config.minZoom);
$zoomOutBtn.attr('aria-disabled', (this.currentZoom <= this.config.minZoom).toString());
}
}
/**
* Setup pan functionality with proper event cleanup
*/
protected setupPanFunctionality(): void {
if (!this.$imageWrapper?.length) return;
// Create bound handlers for cleanup
const handleMouseDown = this.handleMouseDown.bind(this);
const handleMouseMove = this.handleMouseMove.bind(this);
const handleMouseUp = this.handleMouseUp.bind(this);
const handleTouchStart = this.handleTouchStart.bind(this);
const handleTouchMove = this.handleTouchMove.bind(this);
const handlePinchZoom = this.handlePinchZoom.bind(this);
// Store references for cleanup
this.boundHandlers.set('mousedown', handleMouseDown);
this.boundHandlers.set('mousemove', handleMouseMove);
this.boundHandlers.set('mouseup', handleMouseUp);
this.boundHandlers.set('touchstart', handleTouchStart);
this.boundHandlers.set('touchmove', handleTouchMove);
this.boundHandlers.set('pinchzoom', handlePinchZoom);
// Mouse events
this.$imageWrapper.on('mousedown', handleMouseDown);
// Document-level mouse events (for dragging outside wrapper)
$(document).on('mousemove', handleMouseMove);
$(document).on('mouseup', handleMouseUp);
// Touch events
this.$imageWrapper.on('touchstart', handleTouchStart);
this.$imageWrapper.on('touchmove', handleTouchMove);
// Pinch zoom
this.$imageWrapper.on('touchstart', handlePinchZoom);
this.$imageWrapper.on('touchmove', handlePinchZoom);
}
private handleMouseDown(e: JQuery.MouseDownEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
this.isDragging = true;
const offset = this.$imageWrapper.offset();
if (offset) {
this.startX = e.pageX - offset.left;
this.startY = e.pageY - offset.top;
}
this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0;
this.scrollTop = this.$imageWrapper.scrollTop() ?? 0;
this.$imageWrapper.css('cursor', 'grabbing');
e.preventDefault();
}
private handleMouseMove(e: JQuery.MouseMoveEvent): void {
if (!this.isDragging || !this.$imageWrapper) return;
e.preventDefault();
const offset = this.$imageWrapper.offset();
if (offset) {
const x = e.pageX - offset.left;
const y = e.pageY - offset.top;
const walkX = (x - this.startX) * 2;
const walkY = (y - this.startY) * 2;
this.$imageWrapper.scrollLeft(this.scrollLeft - walkX);
this.$imageWrapper.scrollTop(this.scrollTop - walkY);
}
}
private handleMouseUp(): void {
if (this.isDragging) {
this.isDragging = false;
if (this.currentZoom > 1 && this.$imageWrapper) {
this.$imageWrapper.css('cursor', 'move');
}
}
}
private handleTouchStart(e: JQuery.TouchStartEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
const touch = e.originalEvent?.touches[0];
if (touch) {
this.startX = touch.clientX;
this.startY = touch.clientY;
this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0;
this.scrollTop = this.$imageWrapper.scrollTop() ?? 0;
}
}
private handleTouchMove(e: JQuery.TouchMoveEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
const touches = e.originalEvent?.touches;
if (touches && touches.length === 1) {
e.preventDefault();
const touch = touches[0];
const deltaX = this.startX - touch.clientX;
const deltaY = this.startY - touch.clientY;
this.$imageWrapper.scrollLeft(this.scrollLeft + deltaX);
this.$imageWrapper.scrollTop(this.scrollTop + deltaY);
}
}
private initialDistance: number = 0;
private initialZoom: number = 1;
private handlePinchZoom(e: JQuery.TriggeredEvent): void {
const touches = e.originalEvent?.touches;
if (!touches || touches.length !== 2) return;
if (e.type === 'touchstart') {
this.initialDistance = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
this.initialZoom = this.currentZoom;
} else if (e.type === 'touchmove') {
e.preventDefault();
const distance = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
const scale = distance / this.initialDistance;
this.currentZoom = Math.min(Math.max(this.initialZoom * scale, this.config.minZoom), this.config.maxZoom);
this.applyZoom();
}
}
/**
* Setup keyboard navigation with focus check
*/
protected setupKeyboardNavigation(): void {
if (!this.$widget?.length) return;
// Make widget focusable
this.$widget.attr('tabindex', '0');
this.$widget.attr('role', 'application');
this.$widget.attr('aria-label', 'Image viewer with zoom controls');
const handleKeyDown = (e: JQuery.KeyDownEvent) => {
// Only handle keyboard events when widget has focus
if (!this.$widget?.is(':focus-within')) {
return;
}
switch(e.key) {
case '+':
case '=':
e.preventDefault();
e.stopPropagation();
this.zoomIn();
break;
case '-':
case '_':
e.preventDefault();
e.stopPropagation();
this.zoomOut();
break;
case '0':
e.preventDefault();
e.stopPropagation();
this.resetZoom();
break;
case 'Enter':
case ' ':
if (this.isPhotoSwipeAvailable && this.$imageView?.length) {
e.preventDefault();
e.stopPropagation();
const src = this.$imageView.attr('src') || this.$imageView.prop('src');
if (src) {
this.openInLightbox(src, this.note?.title, this.noteId, this.$imageView.get(0));
}
}
break;
case 'Escape':
if (this.isPhotoSwipeAvailable && mediaViewer.isOpen()) {
e.preventDefault();
e.stopPropagation();
mediaViewer.close();
}
break;
case 'ArrowLeft':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) - 50);
}
break;
case 'ArrowRight':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) + 50);
}
break;
case 'ArrowUp':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) - 50);
}
break;
case 'ArrowDown':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) + 50);
}
break;
}
};
this.boundHandlers.set('keydown', handleKeyDown);
this.$widget.on('keydown', handleKeyDown);
}
/**
* Refresh gallery items when content changes
*/
protected async refreshGalleryItems(): Promise<void> {
this.galleryItems = await this.detectGalleryItems();
this.currentImageIndex = 0;
}
/**
* Setup double-click to reset zoom
*/
protected setupDoubleClickReset(): void {
if (!this.$imageView?.length) return;
this.$imageView.on('dblclick', (e) => {
e.preventDefault();
this.resetZoom();
});
}
/**
* Setup context menu for image
*/
protected setupContextMenu(): void {
if (this.$imageView?.length) {
imageContextMenuService.setupContextMenu(this.$imageView);
}
}
/**
* Add ARIA labels for accessibility
*/
protected addAccessibilityLabels(): void {
// Add ARIA labels to control buttons
this.$widget?.find('.zoom-in, .image-control-btn.zoom-in')
.attr('aria-label', 'Zoom in')
.attr('role', 'button');
this.$widget?.find('.zoom-out, .image-control-btn.zoom-out')
.attr('aria-label', 'Zoom out')
.attr('role', 'button');
this.$widget?.find('.fullscreen, .image-control-btn.fullscreen')
.attr('aria-label', 'Open in fullscreen lightbox')
.attr('role', 'button');
this.$widget?.find('.download, .image-control-btn.download')
.attr('aria-label', 'Download image')
.attr('role', 'button');
// Add alt text to image
if (this.$imageView?.length && this.note?.title) {
this.$imageView.attr('alt', this.note.title);
}
}
/**
* Cleanup all event handlers and resources
*/
cleanup() {
// Close gallery or lightbox if open
if (this.isPhotoSwipeAvailable) {
if (galleryManager.isGalleryOpen()) {
galleryManager.closeGallery();
} else if (mediaViewer.isOpen()) {
mediaViewer.close();
}
}
// Clear gallery items
this.galleryItems = [];
this.currentImageIndex = 0;
// Remove document-level event listeners
if (this.boundHandlers.has('mousemove')) {
$(document).off('mousemove', this.boundHandlers.get('mousemove') as any);
}
if (this.boundHandlers.has('mouseup')) {
$(document).off('mouseup', this.boundHandlers.get('mouseup') as any);
}
// Clear all bound handlers
this.boundHandlers.clear();
// Cancel any pending animations
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Clear zoom debounce timer
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
this.zoomDebounceTimer = null;
}
// Clear zoom indicator timer
if (this.$zoomIndicator?.data('hideTimer')) {
clearTimeout(this.$zoomIndicator.data('hideTimer'));
}
super.cleanup();
}
}
export default ImageViewerBase;

View File

@@ -6,7 +6,6 @@ import { getLocaleById } from "../../services/i18n.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import { getMermaidConfig } from "../../services/mermaid.js"; import { getMermaidConfig } from "../../services/mermaid.js";
import { renderMathInElement } from "../../services/math.js"; import { renderMathInElement } from "../../services/math.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/` const TPL = /*html*/`
<div class="note-detail-readonly-text note-detail-printable" tabindex="100"> <div class="note-detail-readonly-text note-detail-printable" tabindex="100">
@@ -94,19 +93,7 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
} }
cleanup() { cleanup() {
// Cleanup PhotoSwipe integration this.$content.html("");
if (this.$content?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$content[0]);
}
// Remove all event handlers from content
if (this.$content) {
this.$content.off();
this.$content.find('*').off();
this.$content.html("");
}
super.cleanup();
} }
async doRefresh(note: FNote) { async doRefresh(note: FNote) {
@@ -120,18 +107,6 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
const blob = await note.getBlob(); const blob = await note.getBlob();
this.$content.html(blob?.content ?? ""); this.$content.html(blob?.content ?? "");
// Setup PhotoSwipe integration for images in read-only content
setTimeout(() => {
if (this.$content[0]) {
ckeditorPhotoSwipe.setupContainer(this.$content[0], {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.no-lightbox'
});
}
}, 100);
this.$content.find("a.reference-link").each((_, el) => { this.$content.find("a.reference-link").each((_, el) => {
this.loadReferenceLinkTitle($(el)); this.loadReferenceLinkTitle($(el));

View File

@@ -674,6 +674,8 @@ export async function getFullCalendarLocale(locale: string) {
return (await import("@fullcalendar/core/locales/ro")).default; return (await import("@fullcalendar/core/locales/ro")).default;
case "ru": case "ru":
return (await import("@fullcalendar/core/locales/ru")).default; return (await import("@fullcalendar/core/locales/ru")).default;
case "ja":
return (await import("@fullcalendar/core/locales/ja")).default;
case "en": case "en":
default: default:
return undefined; return undefined;

View File

@@ -19,6 +19,6 @@
}, },
"devDependencies": { "devDependencies": {
"dotenv": "17.2.1", "dotenv": "17.2.1",
"electron": "37.2.6" "electron": "37.3.0"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@triliumnext/desktop", "name": "@triliumnext/desktop",
"version": "0.97.2", "version": "0.98.0",
"description": "Build your personal knowledge base with Trilium Notes", "description": "Build your personal knowledge base with Trilium Notes",
"private": true, "private": true,
"main": "main.cjs", "main": "main.cjs",
@@ -17,7 +17,7 @@
"@types/electron-squirrel-startup": "1.0.2", "@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*", "@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.1", "copy-webpack-plugin": "13.0.1",
"electron": "37.2.6", "electron": "37.3.0",
"@electron-forge/cli": "7.8.3", "@electron-forge/cli": "7.8.3",
"@electron-forge/maker-deb": "7.8.3", "@electron-forge/maker-deb": "7.8.3",
"@electron-forge/maker-dmg": "7.8.3", "@electron-forge/maker-dmg": "7.8.3",

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*", "@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4", "@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.1", "copy-webpack-plugin": "13.0.1",
"electron": "37.2.6", "electron": "37.3.0",
"fs-extra": "11.3.1" "fs-extra": "11.3.1"
}, },
"nx": { "nx": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@triliumnext/server", "name": "@triliumnext/server",
"version": "0.97.2", "version": "0.98.0",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.", "description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -39,7 +39,7 @@
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/xml2js": "0.4.14", "@types/xml2js": "0.4.14",
"express-http-proxy": "2.1.1", "express-http-proxy": "2.1.1",
"@anthropic-ai/sdk": "0.59.0", "@anthropic-ai/sdk": "0.60.0",
"@braintree/sanitize-url": "7.1.1", "@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*", "@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*", "@triliumnext/express-partial-content": "workspace:*",
@@ -59,7 +59,7 @@
"debounce": "2.2.0", "debounce": "2.2.0",
"debug": "4.4.1", "debug": "4.4.1",
"ejs": "3.1.10", "ejs": "3.1.10",
"electron": "37.2.6", "electron": "37.3.0",
"electron-debug": "4.1.0", "electron-debug": "4.1.0",
"electron-window-state": "5.0.3", "electron-window-state": "5.0.3",
"escape-html": "1.0.3", "escape-html": "1.0.3",
@@ -74,7 +74,7 @@
"html2plaintext": "2.1.4", "html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2", "http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"i18next": "25.3.4", "i18next": "25.3.6",
"i18next-fs-backend": "2.6.0", "i18next-fs-backend": "2.6.0",
"image-type": "6.0.0", "image-type": "6.0.0",
"ini": "5.0.0", "ini": "5.0.0",
@@ -103,7 +103,7 @@
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"time2fa": "^1.3.0", "time2fa": "^1.3.0",
"tmp": "0.2.5", "tmp": "0.2.5",
"turndown": "7.2.0", "turndown": "7.2.1",
"unescape": "1.0.1", "unescape": "1.0.1",
"ws": "8.18.3", "ws": "8.18.3",
"xml2js": "0.6.2", "xml2js": "0.6.2",

View File

@@ -1,6 +1,6 @@
<p>Trilium supports configuration via a file named <code>config.ini</code> and <p>Trilium supports configuration via a file named <code>config.ini</code> and
environment variables. Please review the file named <a href="https://github.com/TriliumNext/Trilium/blob/develop/apps/server/src/assets/config-sample.ini">config-sample.ini</a> in environment variables. Please review the file named <a href="https://github.com/TriliumNext/Trilium/blob/main/apps/server/src/assets/config-sample.ini">config-sample.ini</a> in
the <a href="https://github.com/TriliumNext/Trilium">Notes</a> repository the <a href="https://github.com/TriliumNext/Trilium">Trilium</a> repository
to see what values are supported.</p> to see what values are supported.</p>
<p>You can provide the same values via environment variables instead of the <code>config.ini</code> file, <p>You can provide the same values via environment variables instead of the <code>config.ini</code> file,
and these environment variables use the following format:</p> and these environment variables use the following format:</p>

View File

@@ -231,23 +231,17 @@
first, then adds fuzzy matching when needed.</p> first, then adds fuzzy matching when needed.</p>
<h3>How Progressive Search Works</h3> <h3>How Progressive Search Works</h3>
<ol> <ol>
<li> <li><strong>Phase 1 - Exact Matching</strong>: When you search, Trilium first
<p><strong>Phase 1 - Exact Matching</strong>: When you search, Trilium first looks for exact matches of your search terms. This handles the vast majority
looks for exact matches of your search terms. This handles the vast majority of searches (90%+) and returns results almost instantly.</li>
of searches (90%+) and returns results almost instantly.</p> <li><strong>Phase 2 - Fuzzy Fallback</strong>: If Phase 1 doesn't find enough
</li> high-quality results (fewer than 5 results with good relevance scores),
<li> Trilium automatically adds fuzzy matching to find results with typos or
<p><strong>Phase 2 - Fuzzy Fallback</strong>: If Phase 1 doesn't find enough spelling variations.</li>
high-quality results (fewer than 5 results with good relevance scores), <li><strong>Result Ordering</strong>: Exact matches always appear before fuzzy
Trilium automatically adds fuzzy matching to find results with typos or matches, regardless of individual scores. This ensures that when you search
spelling variations.</p> for "project", notes containing the exact word "project" will appear before
</li> notes containing similar words like "projects" or "projection".</li>
<li>
<p><strong>Result Ordering</strong>: Exact matches always appear before fuzzy
matches, regardless of individual scores. This ensures that when you search
for "project", notes containing the exact word "project" will appear before
notes containing similar words like "projects" or "projection".</p>
</li>
</ol> </ol>
<h3>Progressive Search Behavior</h3> <h3>Progressive Search Behavior</h3>
<ul> <ul>

View File

@@ -2,9 +2,9 @@
<p>There are two types of error logs, both of which are useful when reporting <p>There are two types of error logs, both of which are useful when reporting
bugs.</p> bugs.</p>
<ul> <ul>
<li data-list-item-id="e5d891396e04bcb9d78ea10421e8ee6be"><a class="reference-link" href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/qzNzp9LYQyPT/_help_bnyigUA2UK7s">Backend (server) logs</a> <li><a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a>
</li> </li>
<li data-list-item-id="e94ecb76c9af944e47bf8b5c5dabf027a"><a class="reference-link" href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/qzNzp9LYQyPT/_help_9yEHzMyFirZR">Frontend logs</a> <li><a class="reference-link" href="#root/_help_9yEHzMyFirZR">Frontend logs</a>
</li> </li>
</ul> </ul>
<h2>Providing sensitive data</h2> <h2>Providing sensitive data</h2>

View File

@@ -1,10 +1,10 @@
<h2>Accessing via the backend log</h2> <h2>Accessing via the backend log</h2>
<p>In the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_x3i7MxGccDuM">Global menu</a>, <p>In the&nbsp;<a class="reference-link" href="#root/_help_x3i7MxGccDuM">Global menu</a>,
go to Advanced → Show backend log</p> go to Advanced → Show backend log</p>
<h2>Location on the disk</h2> <h2>Location on the disk</h2>
<p>Backend logs are stored on the file system. To find them, open the&nbsp; <p>Backend logs are stored on the file system. To find them, open the&nbsp;
<a <a
class="reference-link" href="#root/pOsGYCXsbNQG/Otzi9La2YAUX/_help_tAassRL4RSQL">Data directory</a>, go to the <code>log</code> subdirectory and find the class="reference-link" href="#root/_help_tAassRL4RSQL">Data directory</a>, go to the <code>log</code> subdirectory and find the
latest log file, e.g. <code>trilium-2022-12-14.log</code>.&nbsp;</p> latest log file, e.g. <code>trilium-2022-12-14.log</code>.&nbsp;</p>
<h2>Reporting backend bugs</h2> <h2>Reporting backend bugs</h2>
<p>You can attach the whole file to the bug report (preferable) or open it <p>You can attach the whole file to the bug report (preferable) or open it
@@ -15,12 +15,18 @@
in order to reduce the space consumption.</p> in order to reduce the space consumption.</p>
<p>It's possible to change the retention period by modifying the&nbsp; <p>It's possible to change the retention period by modifying the&nbsp;
<a <a
class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a>&nbsp;via the <code>.ini</code> file:</p><pre><code class="language-text-x-trilium-auto">[Logging] class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a>&nbsp;via the <code>.ini</code> file:</p><pre><code class="language-text-x-trilium-auto">[Logging]
retentionDays=7</code></pre> retentionDays=7</code></pre>
<p>Or via the environment variable <code>TRILIUM_LOGGING_RETENTION_DAYS</code>.</p> <p>Or via the environment variable <code>TRILIUM_LOGGING_RETENTION_DAYS</code>.</p>
<aside <p>Special cases:</p>
class="admonition note"> <ul>
<li>Positive values indicate the number of days worth of logs to keep</li>
<li>A value of 0 results with the default value (90 days) to be used</li>
<li>Negative values (e.g. <code>-1</code>) result with all logs to be kept,
irrespective how ancient and numerous (and</li>
</ul>
<aside class="admonition note">
<p>If you set the retention days to a low number, you might notice that not <p>If you set the retention days to a low number, you might notice that not
all the log files are being deleted. This is because a minimum number of all the log files are being deleted. This is because a minimum number of
logs (7 at the time of writing) is maintained at all times.</p> logs (7 at the time of writing) is maintained at all times.</p>
</aside> </aside>

View File

@@ -5,9 +5,9 @@
console is cleared on app restart.</p> console is cleared on app restart.</p>
<p>If that doesn't work, then:</p> <p>If that doesn't work, then:</p>
<ul> <ul>
<li data-list-item-id="e1f836ac8425c0d56072614c40c996bbd">in Trilium desktop app, go to top-left menu button -&gt; Advanced -&gt; <li>in Trilium desktop app, go to top-left menu button -&gt; Advanced -&gt;
Open Dev Tools</li> Open Dev Tools</li>
<li data-list-item-id="e8bd27605bedefe884b826a1899413a6d">In Firefox/Chrome right-click anywhere in the page and click Inspect:</li> <li>In Firefox/Chrome right-click anywhere in the page and click Inspect:</li>
</ul> </ul>
<p> <p>
<img src="Frontend logs_error-logs-f.png"> <img src="Frontend logs_error-logs-f.png">

View File

@@ -0,0 +1,14 @@
{
"keyboard_actions": {
"back-in-note-history": "به یادداشت قبلی در تاریخچه",
"forward-in-note-history": "به یادداشت بعدی در تاریخچه برو",
"open-jump-to-note-dialog": "باز کردن پنجرهٔ «پرش به یادداشت»",
"open-command-palette": "باز کردن پالت دستورات",
"scroll-to-active-note": "پیمایش درخت یادداشت به یادداشت جاری",
"quick-search": "فعال‌سازی نوار جستجوی سریع",
"search-in-subtree": "‪جستجوی یادداشت‌ها در زیردرخت یادداشت فعال",
"expand-subtree": "باز کردن زیردرخت یادداشت جاری",
"collapse-tree": "بستن کامل درخت یادداشت‌ها",
"collapse-subtree": "بستن زیرشاخه‌های یادداشت فعلی"
}
}

View File

@@ -0,0 +1,10 @@
{
"keyboard_actions": {
"quick-search": "Aktivoi pikahakupalkki",
"collapse-tree": "Kutista valmis muistiinpanopuu",
"creating-and-moving-notes": "Luo ja siirrä muistioita",
"delete-note": "Poista muistio",
"move-note-up": "Siirrä muistio ylös",
"open-command-palette": "Avaa komentovalikko"
}
}

View File

@@ -1,287 +1,355 @@
{ {
"keyboard_actions": { "keyboard_actions": {
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"", "open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active", "search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
"expand-subtree": "Développer le sous-arbre de la note actuelle", "expand-subtree": "Développer le sous-arbre de la note actuelle",
"collapse-tree": "Réduire toute l'arborescence des notes", "collapse-tree": "Réduire toute l'arborescence des notes",
"collapse-subtree": "Réduire le sous-arbre de la note actuelle", "collapse-subtree": "Réduire le sous-arbre de la note actuelle",
"sort-child-notes": "Trier les notes enfants", "sort-child-notes": "Trier les notes enfants",
"creating-and-moving-notes": "Créer et déplacer des notes", "creating-and-moving-notes": "Créer et déplacer des notes",
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière", "create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
"delete-note": "Supprimer la note", "delete-note": "Supprimer la note",
"move-note-up": "Déplacer la note vers le haut", "move-note-up": "Déplacer la note vers le haut",
"move-note-down": "Déplacer la note vers le bas", "move-note-down": "Déplacer la note vers le bas",
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie", "move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie", "move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre", "edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche", "edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
"note-clipboard": "Note presse-papiers", "note-clipboard": "Note presse-papiers",
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers", "copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active", "paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers", "cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active", "select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection", "add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection", "add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
"duplicate-subtree": "Dupliquer le sous-arbre", "duplicate-subtree": "Dupliquer le sous-arbre",
"tabs-and-windows": "Onglets et fenêtres", "tabs-and-windows": "Onglets et fenêtres",
"open-new-tab": "Ouvrir un nouvel onglet", "open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif", "close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Rouvrir le dernier onglet fermé", "reopen-last-tab": "Rouvrir le dernier onglet fermé",
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif", "activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif", "activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
"open-new-window": "Ouvrir une nouvelle fenêtre vide", "open-new-window": "Ouvrir une nouvelle fenêtre vide",
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches", "toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
"first-tab": "Basculer vers le premier onglet dans la liste", "first-tab": "Basculer vers le premier onglet dans la liste",
"second-tab": "Basculer vers le deuxième onglet dans la liste", "second-tab": "Basculer vers le deuxième onglet dans la liste",
"third-tab": "Basculer vers le troisième onglet dans la liste", "third-tab": "Basculer vers le troisième onglet dans la liste",
"fourth-tab": "Basculer vers le quatrième onglet dans la liste", "fourth-tab": "Basculer vers le quatrième onglet dans la liste",
"fifth-tab": "Basculer vers le cinquième onglet dans la liste", "fifth-tab": "Basculer vers le cinquième onglet dans la liste",
"sixth-tab": "Basculer vers le sixième onglet dans la liste", "sixth-tab": "Basculer vers le sixième onglet dans la liste",
"seventh-tab": "Basculer vers le septième onglet dans la liste", "seventh-tab": "Basculer vers le septième onglet dans la liste",
"eight-tab": "Basculer vers le huitième onglet dans la liste", "eight-tab": "Basculer vers le huitième onglet dans la liste",
"ninth-tab": "Basculer vers le neuvième onglet dans la liste", "ninth-tab": "Basculer vers le neuvième onglet dans la liste",
"last-tab": "Basculer vers le dernier onglet dans la liste", "last-tab": "Basculer vers le dernier onglet dans la liste",
"dialogs": "Boîtes de dialogue", "dialogs": "Boîtes de dialogue",
"show-note-source": "Affiche la boîte de dialogue Source de la note", "show-note-source": "Affiche la boîte de dialogue Source de la note",
"show-options": "Afficher les Options", "show-options": "Afficher les Options",
"show-revisions": "Afficher la boîte de dialogue Versions de la note", "show-revisions": "Afficher la boîte de dialogue Versions de la note",
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes", "show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
"show-sql-console": "Afficher la boîte de dialogue Console SQL", "show-sql-console": "Afficher la boîte de dialogue Console SQL",
"show-backend-log": "Afficher la boîte de dialogue Journal du backend", "show-backend-log": "Afficher la boîte de dialogue Journal du backend",
"text-note-operations": "Opérations sur les notes textuelles", "text-note-operations": "Opérations sur les notes textuelles",
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte", "add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
"follow-link-under-cursor": "Suivre le lien sous le curseur", "follow-link-under-cursor": "Suivre le lien sous le curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte", "insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers", "paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné", "cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note", "add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
"edit-readonly-note": "Éditer une note en lecture seule", "edit-readonly-note": "Éditer une note en lecture seule",
"attributes-labels-and-relations": "Attributs (labels et relations)", "attributes-labels-and-relations": "Attributs (labels et relations)",
"add-new-label": "Créer un nouveau label", "add-new-label": "Créer un nouveau label",
"create-new-relation": "Créer une nouvelle relation", "create-new-relation": "Créer une nouvelle relation",
"ribbon-tabs": "Onglets du ruban", "ribbon-tabs": "Onglets du ruban",
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note", "toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier", "toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image", "toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-owned-attributes": "Afficher/masquer les Attributs propres", "toggle-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités", "toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus", "toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
"toggle-link-map": "Afficher/masquer la Carte de la note", "toggle-link-map": "Afficher/masquer la Carte de la note",
"toggle-note-info": "Afficher/masquer les Informations de la note", "toggle-note-info": "Afficher/masquer les Informations de la note",
"toggle-note-paths": "Afficher/masquer les Emplacements de la note", "toggle-note-paths": "Afficher/masquer les Emplacements de la note",
"toggle-similar-notes": "Afficher/masquer les Notes similaires", "toggle-similar-notes": "Afficher/masquer les Notes similaires",
"other": "Autre", "other": "Autre",
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations", "toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
"print-active-note": "Imprimer la note active", "print-active-note": "Imprimer la note active",
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut", "open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
"render-active-note": "Rendre (ou re-rendre) la note active", "render-active-note": "Rendre (ou re-rendre) la note active",
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active", "run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
"toggle-note-hoisting": "Activer le focus sur la note active", "toggle-note-hoisting": "Activer le focus sur la note active",
"unhoist": "Désactiver tout focus", "unhoist": "Désactiver tout focus",
"reload-frontend-app": "Recharger l'application", "reload-frontend-app": "Recharger l'application",
"open-dev-tools": "Ouvrir les outils de développement", "open-dev-tools": "Ouvrir les outils de développement",
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)", "toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
"toggle-full-screen": "Basculer en plein écran", "toggle-full-screen": "Basculer en plein écran",
"zoom-out": "Dézoomer", "zoom-out": "Dézoomer",
"zoom-in": "Zoomer", "zoom-in": "Zoomer",
"note-navigation": "Navigation dans les notes", "note-navigation": "Navigation dans les notes",
"reset-zoom-level": "Réinitialiser le niveau de zoom", "reset-zoom-level": "Réinitialiser le niveau de zoom",
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme", "copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active", "force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
"show-help": "Affiche le guide de l'utilisateur intégré", "show-help": "Affiche le guide de l'utilisateur intégré",
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre", "toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe", "toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"export-as-pdf": "Exporte la note actuelle en PDF", "export-as-pdf": "Exporte la note actuelle en PDF",
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes", "show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)", "toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
"back-in-note-history": "Naviguer à la note précédente dans l'historique", "back-in-note-history": "Naviguer à la note précédente dans l'historique",
"forward-in-note-history": "Naviguer a la note suivante dans l'historique", "forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes", "open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés", "clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés" "move-notes-to": "Déplacer les nœuds sélectionnés",
}, "scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"login": { "quick-search": "Activer la barre de recherche rapide",
"title": "Connexion", "create-note-after": "Créer une note après la note active",
"heading": "Connexion à Trilium", "create-note-into": "Créer une note enfant de la note active",
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.", "find-in-text": "Afficher/Masquer le panneau de recherche"
"password": "Mot de passe", },
"remember-me": "Se souvenir de moi", "login": {
"button": "Connexion" "title": "Connexion",
}, "heading": "Connexion à Trilium",
"set_password": { "incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
"title": "Définir un mot de passe", "password": "Mot de passe",
"heading": "Définir un mot de passe", "remember-me": "Se souvenir de moi",
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.", "button": "Connexion"
"password": "Mot de passe", },
"password-confirmation": "Confirmation du mot de passe", "set_password": {
"button": "Définir le mot de passe" "title": "Définir un mot de passe",
}, "heading": "Définir un mot de passe",
"javascript-required": "Trilium nécessite que JavaScript soit activé.", "description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
"setup": { "password": "Mot de passe",
"heading": "Configuration de Trilium Notes", "password-confirmation": "Confirmation du mot de passe",
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes", "button": "Définir le mot de passe"
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci", },
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci", "javascript-required": "Trilium nécessite que JavaScript soit activé.",
"next": "Suivant", "setup": {
"init-in-progress": "Initialisation du document en cours", "heading": "Configuration de Trilium Notes",
"redirecting": "Vous serez bientôt redirigé vers l'application.", "new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
"title": "Configuration" "sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
}, "sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
"setup_sync-from-desktop": { "next": "Suivant",
"heading": "Synchroniser depuis une application de bureau", "init-in-progress": "Initialisation du document en cours",
"description": "Cette procédure doit être réalisée depuis l'application de bureau installée sur votre ordinateur:", "redirecting": "Vous serez bientôt redirigé vers l'application.",
"step1": "Ouvrez l'application Trilium Notes.", "title": "Configuration"
"step2": "Dans le menu Trilium, cliquez sur Options.", },
"step3": "Cliquez sur la catégorie Synchroniser.", "setup_sync-from-desktop": {
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.", "heading": "Synchroniser depuis une application de bureau",
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.", "description": "Cette procédure doit être réalisée depuis l'application de bureau :",
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.", "step1": "Ouvrez l'application Trilium Notes.",
"step6-here": "ici" "step2": "Dans le menu Trilium, cliquez sur Options.",
}, "step3": "Cliquez sur la catégorie Synchroniser.",
"setup_sync-from-server": { "step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
"heading": "Synchroniser depuis le serveur", "step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.", "step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
"server-host": "Adresse du serveur Trilium", "step6-here": "ici"
"server-host-placeholder": "https://<nom d'hôte>:<port>", },
"proxy-server": "Serveur proxy (facultatif)", "setup_sync-from-server": {
"proxy-server-placeholder": "https://<nom d'hôte>:<port>", "heading": "Synchroniser depuis le serveur",
"note": "Note :", "instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)", "server-host": "Adresse du serveur Trilium",
"password": "Mot de passe", "server-host-placeholder": "https://<nom d'hôte>:<port>",
"password-placeholder": "Mot de passe", "proxy-server": "Serveur proxy (facultatif)",
"back": "Retour", "proxy-server-placeholder": "https://<nom d'hôte>:<port>",
"finish-setup": "Terminer" "note": "Note :",
}, "proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
"setup_sync-in-progress": { "password": "Mot de passe",
"heading": "Synchronisation en cours", "password-placeholder": "Mot de passe",
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.", "back": "Retour",
"outstanding-items": "Éléments de synchronisation exceptionnels :", "finish-setup": "Terminer"
"outstanding-items-default": "N/A" },
}, "setup_sync-in-progress": {
"share_404": { "heading": "Synchronisation en cours",
"title": "Page non trouvée", "successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
"heading": "Page non trouvée" "outstanding-items": "Éléments de synchronisation exceptionnels :",
}, "outstanding-items-default": "N/A"
"share_page": { },
"parent": "parent :", "share_404": {
"clipped-from": "Cette note a été initialement extraite de {{- url}}", "title": "Page non trouvée",
"child-notes": "Notes enfants :", "heading": "Page non trouvée"
"no-content": "Cette note n'a aucun contenu." },
}, "share_page": {
"weekdays": { "parent": "parent :",
"monday": "Lundi", "clipped-from": "Cette note a été initialement extraite de {{- url}}",
"tuesday": "Mardi", "child-notes": "Notes enfants :",
"wednesday": "Mercredi", "no-content": "Cette note n'a aucun contenu."
"thursday": "Jeudi", },
"friday": "Vendredi", "weekdays": {
"saturday": "Samedi", "monday": "Lundi",
"sunday": "Dimanche" "tuesday": "Mardi",
}, "wednesday": "Mercredi",
"months": { "thursday": "Jeudi",
"january": "Janvier", "friday": "Vendredi",
"february": "Février", "saturday": "Samedi",
"march": "Mars", "sunday": "Dimanche"
"april": "Avril", },
"may": "Mai", "months": {
"june": "Juin", "january": "Janvier",
"july": "Juillet", "february": "Février",
"august": "Août", "march": "Mars",
"september": "Septembre", "april": "Avril",
"october": "Octobre", "may": "Mai",
"november": "Novembre", "june": "Juin",
"december": "Décembre" "july": "Juillet",
}, "august": "Août",
"special_notes": { "september": "Septembre",
"search_prefix": "Recherche :" "october": "Octobre",
}, "november": "Novembre",
"test_sync": { "december": "Décembre"
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.", },
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée." "special_notes": {
}, "search_prefix": "Recherche :"
"hidden-subtree": { },
"root-title": "Notes cachées", "test_sync": {
"search-history-title": "Historique de recherche", "not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
"note-map-title": "Carte de la Note", "successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
"sql-console-history-title": "Historique de la console SQL", },
"shared-notes-title": "Notes partagées", "hidden-subtree": {
"bulk-action-title": "Action groupée", "root-title": "Notes cachées",
"backend-log-title": "Journal Backend", "search-history-title": "Historique de recherche",
"user-hidden-title": "Utilisateur masqué", "note-map-title": "Carte de la Note",
"launch-bar-templates-title": "Modèles de barre de raccourcis", "sql-console-history-title": "Historique de la console SQL",
"base-abstract-launcher-title": "Raccourci Base abstraite", "shared-notes-title": "Notes partagées",
"command-launcher-title": "Raccourci Commande", "bulk-action-title": "Action groupée",
"note-launcher-title": "Raccourci Note", "backend-log-title": "Journal Backend",
"script-launcher-title": "Raccourci Script", "user-hidden-title": "Utilisateur masqué",
"built-in-widget-title": "Widget intégré", "launch-bar-templates-title": "Modèles de barre de raccourcis",
"spacer-title": "Séparateur", "base-abstract-launcher-title": "Raccourci Base abstraite",
"custom-widget-title": "Widget personnalisé", "command-launcher-title": "Raccourci Commande",
"launch-bar-title": "Barre de lancement", "note-launcher-title": "Raccourci Note",
"available-launchers-title": "Raccourcis disponibles", "script-launcher-title": "Raccourci Script",
"go-to-previous-note-title": "Aller à la note précédente", "built-in-widget-title": "Widget intégré",
"go-to-next-note-title": "Aller à la note suivante", "spacer-title": "Séparateur",
"new-note-title": "Nouvelle note", "custom-widget-title": "Widget personnalisé",
"search-notes-title": "Rechercher des notes", "launch-bar-title": "Barre de lancement",
"calendar-title": "Calendrier", "available-launchers-title": "Raccourcis disponibles",
"recent-changes-title": "Modifications récentes", "go-to-previous-note-title": "Aller à la note précédente",
"bookmarks-title": "Signets", "go-to-next-note-title": "Aller à la note suivante",
"open-today-journal-note-title": "Ouvrir la note du journal du jour", "new-note-title": "Nouvelle note",
"quick-search-title": "Recherche rapide", "search-notes-title": "Rechercher des notes",
"protected-session-title": "Session protégée", "calendar-title": "Calendrier",
"sync-status-title": "État de la synchronisation", "recent-changes-title": "Modifications récentes",
"settings-title": "Réglages", "bookmarks-title": "Signets",
"options-title": "Options", "open-today-journal-note-title": "Ouvrir la note du journal du jour",
"appearance-title": "Apparence", "quick-search-title": "Recherche rapide",
"shortcuts-title": "Raccourcis", "protected-session-title": "Session protégée",
"text-notes": "Notes de texte", "sync-status-title": "État de la synchronisation",
"code-notes-title": "Notes de code", "settings-title": "Réglages",
"images-title": "Images", "options-title": "Options",
"spellcheck-title": "Correcteur orthographique", "appearance-title": "Apparence",
"password-title": "Mot de passe", "shortcuts-title": "Raccourcis",
"etapi-title": "ETAPI", "text-notes": "Notes de texte",
"backup-title": "Sauvegarde", "code-notes-title": "Notes de code",
"sync-title": "Synchronisation", "images-title": "Images",
"other": "Autre", "spellcheck-title": "Correcteur orthographique",
"advanced-title": "Avancé", "password-title": "Mot de passe",
"visible-launchers-title": "Raccourcis visibles", "etapi-title": "ETAPI",
"user-guide": "Guide de l'utilisateur" "backup-title": "Sauvegarde",
}, "sync-title": "Synchronisation",
"notes": { "other": "Autre",
"new-note": "Nouvelle note", "advanced-title": "Avancé",
"duplicate-note-suffix": "(dup)", "visible-launchers-title": "Raccourcis visibles",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}" "user-guide": "Guide de l'utilisateur"
}, },
"backend_log": { "notes": {
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).", "new-note": "Nouvelle note",
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué." "duplicate-note-suffix": "(dup)",
}, "duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
"content_renderer": { },
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché." "backend_log": {
}, "log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
"pdf": { "reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
"export_filter": "Document PDF (*.pdf)", },
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.", "content_renderer": {
"unable-to-export-title": "Impossible d'exporter au format PDF", "note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination." },
}, "pdf": {
"tray": { "export_filter": "Document PDF (*.pdf)",
"tooltip": "Trilium Notes", "unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"close": "Quitter Trilium", "unable-to-export-title": "Impossible d'exporter au format PDF",
"recents": "Notes récentes", "unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
"bookmarks": "Signets", },
"today": "Ouvrir la note du journal du jour", "tray": {
"new-note": "Nouvelle note", "tooltip": "Trilium Notes",
"show-windows": "Afficher les fenêtres" "close": "Quitter Trilium",
}, "recents": "Notes récentes",
"migration": { "bookmarks": "Signets",
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.", "today": "Ouvrir la note du journal du jour",
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}", "new-note": "Nouvelle note",
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème." "show-windows": "Afficher les fenêtres"
}, },
"modals": { "migration": {
"error_title": "Erreur" "old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
}, "error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
"keyboard_action_names": { "wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
"command-palette": "Palette de commandes", },
"quick-search": "Recherche rapide" "modals": {
} "error_title": "Erreur"
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
"duplicate-subtree": "Dupliquer la sous-arborescence",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Réouvrir le dernier onglet",
"activate-next-tab": "Activer l'onglet suivant",
"activate-previous-tab": "Activer l'onglet précédent",
"open-new-window": "Ouvrir une nouvelle fenêtre",
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
"switch-to-first-tab": "Aller au premier onglet",
"switch-to-second-tab": "Aller au second onglet",
"switch-to-third-tab": "Aller au troisième onglet",
"switch-to-fourth-tab": "Aller au quatrième onglet",
"switch-to-fifth-tab": "Aller au cinquième onglet",
"switch-to-sixth-tab": "Aller au sixième onglet",
"switch-to-seventh-tab": "Aller au septième onglet",
"switch-to-eighth-tab": "Aller au huitième onglet",
"switch-to-ninth-tab": "Aller au neuvième onglet",
"switch-to-last-tab": "Aller au dernier onglet",
"show-note-source": "Afficher la source de la note",
"show-options": "Afficher les options",
"show-revisions": "Afficher les révisions",
"show-recent-changes": "Afficher les changements récents",
"show-sql-console": "Afficher la console SQL",
"show-backend-log": "Afficher le journal du backend",
"show-help": "Afficher l'aide",
"show-cheatsheet": "Afficher la fiche de triche",
"add-link-to-text": "Ajouter un lien au texte",
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du Markdown dans le texte",
"cut-into-note": "Couper dans une note",
"add-include-note-to-text": "Ajouter une note inclusion au texte",
"edit-read-only-note": "Modifier une note en lecture seule",
"add-new-label": "Ajouter une nouvelle étiquette",
"add-new-relation": "Ajouter une nouvelle relation"
}
} }

View File

@@ -6,20 +6,20 @@
"open-command-palette": "コマンドパレットを開く", "open-command-palette": "コマンドパレットを開く",
"scroll-to-active-note": "ノートツリーをアクティブなノートまでスクロールする", "scroll-to-active-note": "ノートツリーをアクティブなノートまでスクロールする",
"search-in-subtree": "アクティブなノートのサブツリー内でノートを検索する", "search-in-subtree": "アクティブなノートのサブツリー内でノートを検索する",
"expand-subtree": "現在のノートのサブツリーを展開する", "expand-subtree": "現在のノートのサブツリーを展開",
"collapse-tree": "ノートツリー全体を折りたたむ", "collapse-tree": "ノートツリー全体を折りたたむ",
"collapse-subtree": "現在のノートのサブツリーを折りたたむ", "collapse-subtree": "現在のノートのサブツリーを折りたたむ",
"creating-and-moving-notes": "ノートの作成と移動", "creating-and-moving-notes": "ノートの作成と移動",
"create-note-after": "アクティブなノートの後にノートを作成する", "create-note-after": "アクティブなノートの後にノートを作成する",
"create-note-into": "アクティブなノートの子ノートを作成する", "create-note-into": "アクティブなノートの子ノートを作成する",
"delete-note": "ノートを削除する", "delete-note": "ノートを削除",
"move-note-up": "ノートを上に移動する", "move-note-up": "ノートを上に移動",
"move-note-down": "ノートを下に移動する", "move-note-down": "ノートを下に移動",
"move-note-up-in-hierarchy": "ノートを上の階層に移動する", "move-note-up-in-hierarchy": "ノートを上の階層に移動",
"move-note-down-in-hierarchy": "ノートを下の階層に移動する", "move-note-down-in-hierarchy": "ノートを下の階層に移動",
"edit-note-title": "ツリーからノートの詳細にジャンプして、タイトルを編集", "edit-note-title": "ツリーからノートの詳細にジャンプして、タイトルを編集",
"clone-notes-to": "選択したノートを複製する", "clone-notes-to": "選択したノートを複製",
"move-notes-to": "選択したノートを移動する", "move-notes-to": "選択したノートを移動",
"copy-notes-to-clipboard": "選択したノートをクリップボードにコピー", "copy-notes-to-clipboard": "選択したノートをクリップボードにコピー",
"paste-notes-from-clipboard": "クリップボードからアクティブなノートにノートを貼り付け", "paste-notes-from-clipboard": "クリップボードからアクティブなノートにノートを貼り付け",
"cut-notes-to-clipboard": "選択したノートをクリップボードにカット", "cut-notes-to-clipboard": "選択したノートをクリップボードにカット",
@@ -53,7 +53,7 @@
"show-help": "内蔵のユーザーガイドを開く", "show-help": "内蔵のユーザーガイドを開く",
"show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する", "show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する",
"text-note-operations": "テキストノート操作", "text-note-operations": "テキストノート操作",
"add-link-to-text": "テキストにリンクを追加するダイアログを開く", "add-link-to-text": "テキストにリンクを追加ダイアログを開く",
"follow-link-under-cursor": "カーソル下のリンク先へ移動", "follow-link-under-cursor": "カーソル下のリンク先へ移動",
"insert-date-and-time-to-text": "現在の日時を挿入する", "insert-date-and-time-to-text": "現在の日時を挿入する",
"paste-markdown-into-text": "クリップボードからMarkdownをテキストートに貼り付けます", "paste-markdown-into-text": "クリップボードからMarkdownをテキストートに貼り付けます",
@@ -70,7 +70,7 @@
"open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く", "open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く",
"render-active-note": "アクティブなノートを再描画(再レンダリング)する", "render-active-note": "アクティブなノートを再描画(再レンダリング)する",
"run-active-note": "アクティブなJavaScriptフロントエンド/バックエンド)のコードノートを実行する", "run-active-note": "アクティブなJavaScriptフロントエンド/バックエンド)のコードノートを実行する",
"reload-frontend-app": "フロントエンドリロード", "reload-frontend-app": "フロントエンドリロード",
"open-dev-tools": "開発者ツールを開く", "open-dev-tools": "開発者ツールを開く",
"find-in-text": "検索パネルの切り替え", "find-in-text": "検索パネルの切り替え",
"toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)", "toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)",
@@ -85,7 +85,22 @@
"sort-child-notes": "子ノートを並べ替える", "sort-child-notes": "子ノートを並べ替える",
"create-note-into-inbox": "inbox定義されている場合またはデイートにートを作成する", "create-note-into-inbox": "inbox定義されている場合またはデイートにートを作成する",
"note-clipboard": "ノートクリップボード", "note-clipboard": "ノートクリップボード",
"duplicate-subtree": "サブツリーの複製" "duplicate-subtree": "サブツリーの複製",
"edit-branch-prefix": "「ブランチ接頭辞の編集」ダイアログを表示",
"show-revisions": "「ノート変更履歴」ダイアログを表示",
"attributes-labels-and-relations": "属性(ラベルと関係)",
"add-new-label": "新しいラベルを作成する",
"create-new-relation": "新しい関係を作成する",
"toggle-basic-properties": "基本属性切り替え",
"toggle-file-properties": "ファイル属性切り替え",
"toggle-image-properties": "画像属性切り替え",
"toggle-owned-attributes": "所有属性切り替え",
"toggle-inherited-attributes": "継承属性切り替え",
"toggle-note-hoisting": "ノートホイスト切り替え",
"unhoist": "すべてのホイストを無効にする",
"toggle-book-properties": "コレクションプロパティ切り替え",
"toggle-zen-mode": "ゼンモード集中した編集のための最小限のUIを有効/無効にする",
"add-include-note-to-text": "ノートを埋め込むダイアログを開く"
}, },
"keyboard_action_names": { "keyboard_action_names": {
"back-in-note-history": "ノートの履歴を戻る", "back-in-note-history": "ノートの履歴を戻る",
@@ -156,7 +171,31 @@
"toggle-left-pane": "左ペイン切り替え", "toggle-left-pane": "左ペイン切り替え",
"toggle-full-screen": "フルスクリーンの切り替え", "toggle-full-screen": "フルスクリーンの切り替え",
"copy-without-formatting": "書式なしでコピー", "copy-without-formatting": "書式なしでコピー",
"duplicate-subtree": "サブツリーの複製" "duplicate-subtree": "サブツリーの複製",
"create-note-into-inbox": "Inboxにートを作成",
"toggle-zen-mode": "禅モードの切り替え",
"reset-zoom-level": "ズームレベルのリセット",
"zoom-out": "ズームアウト",
"zoom-in": "ズームイン",
"jump-to-note": "ジャンプ先…",
"edit-branch-prefix": "ブランチ接頭辞の編集",
"show-revisions": "変更履歴を表示",
"add-new-label": "ラベルを追加",
"add-new-relation": "関係を追加",
"toggle-ribbon-tab-basic-properties": "リボンタブ切り替え:基本属性",
"toggle-ribbon-tab-book-properties": "リボンタブ切り替え:書籍属性",
"toggle-ribbon-tab-file-properties": "リボンタブ切り替え:ファイル属性",
"toggle-ribbon-tab-image-properties": "リボンタブ切り替え:画像属性",
"toggle-ribbon-tab-owned-attributes": "リボンタブ切り替え:自有属性",
"toggle-ribbon-tab-inherited-attributes": "リボンタブ切り替え:継承属性",
"toggle-ribbon-tab-note-map": "リボンタブ切り替え:ノートマップ",
"toggle-ribbon-tab-note-info": "リボンタブ切り替え:ノート情報",
"toggle-ribbon-tab-note-paths": "リボンタブ切り替え:ノートパス",
"toggle-ribbon-tab-similar-notes": "リボンタブ切り替え:類似ノート",
"toggle-note-hoisting": "ノートホイスト切り替え",
"unhoist-note": "ノートホイストを無効にする",
"force-save-revision": "強制保存リビジョン",
"add-include-note-to-text": "埋め込みノートを追加"
}, },
"login": { "login": {
"title": "ログイン", "title": "ログイン",
@@ -164,14 +203,17 @@
"incorrect-totp": "TOTPが正しくありません。もう一度お試しください。", "incorrect-totp": "TOTPが正しくありません。もう一度お試しください。",
"incorrect-password": "パスワードが正しくありません。もう一度お試しください。", "incorrect-password": "パスワードが正しくありません。もう一度お試しください。",
"password": "パスワード", "password": "パスワード",
"button": "ログイン" "button": "ログイン",
"remember-me": "ログイン情報を記憶する",
"sign_in_with_sso": "{{ ssoIssuerName }}でログイン"
}, },
"set_password": { "set_password": {
"title": "パスワードの設定", "title": "パスワードの設定",
"heading": "パスワードの設定", "heading": "パスワードの設定",
"description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。", "description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。",
"password": "パスワード", "password": "パスワード",
"button": "パスワードの設定" "button": "パスワードの設定",
"password-confirmation": "パスワードの再入力"
}, },
"javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。", "javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。",
"setup": { "setup": {
@@ -180,7 +222,9 @@
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい", "sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい", "sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
"init-in-progress": "ドキュメントの初期化処理を実行中", "init-in-progress": "ドキュメントの初期化処理を実行中",
"redirecting": "まもなくアプリケーションにリダイレクトされます。" "redirecting": "まもなくアプリケーションにリダイレクトされます。",
"next": "次へ",
"title": "セットアップ"
}, },
"setup_sync-from-desktop": { "setup_sync-from-desktop": {
"heading": "デスクトップから同期", "heading": "デスクトップから同期",
@@ -190,7 +234,8 @@
"step3": "同期をクリックします。", "step3": "同期をクリックします。",
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。", "step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。", "step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。" "step6": "これらのステップを完了したら、{{- link}} をクリックしてください。",
"step6-here": "ここ"
}, },
"setup_sync-from-server": { "setup_sync-from-server": {
"heading": "サーバーから同期", "heading": "サーバーから同期",
@@ -202,7 +247,8 @@
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)", "proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
"password": "パスワード", "password": "パスワード",
"password-placeholder": "パスワード", "password-placeholder": "パスワード",
"finish-setup": "セットアップ完了" "finish-setup": "セットアップ完了",
"back": "戻る"
}, },
"setup_sync-in-progress": { "setup_sync-in-progress": {
"heading": "同期中", "heading": "同期中",
@@ -237,7 +283,8 @@
"search_prefix": "検索:" "search_prefix": "検索:"
}, },
"test_sync": { "test_sync": {
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。" "not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。",
"successful": "同期サーバーとのハンドシェイクが成功しました。同期が開始されました。"
}, },
"hidden-subtree": { "hidden-subtree": {
"search-history-title": "検索履歴", "search-history-title": "検索履歴",
@@ -254,7 +301,40 @@
"other": "その他", "other": "その他",
"advanced-title": "高度", "advanced-title": "高度",
"user-guide": "ユーザーガイド", "user-guide": "ユーザーガイド",
"localization": "言語と地域" "localization": "言語と地域",
"sql-console-history-title": "SQLコンソール履歴",
"new-note-title": "新しいノート",
"bookmarks-title": "ブックマーク",
"open-today-journal-note-title": "今日の日記を開く",
"quick-search-title": "クイックサーチ",
"recent-changes-title": "最近の変更",
"root-title": "隠されたノート",
"note-map-title": "ノートマップ",
"shared-notes-title": "共有ノート",
"bulk-action-title": "一括操作",
"backend-log-title": "バックエンドログ",
"user-hidden-title": "非表示のユーザー",
"launch-bar-templates-title": "ランチャーバーテンプレート",
"command-launcher-title": "コマンドランチャー",
"note-launcher-title": "ノートランチャー",
"script-launcher-title": "スクリプトランチャー",
"built-in-widget-title": "内蔵のウィジェット",
"spacer-title": "スペーサー",
"custom-widget-title": "カスタムウィジェット",
"launch-bar-title": "ランチャーバー",
"available-launchers-title": "利用可能なランチャー",
"go-to-previous-note-title": "前のノートに移動",
"go-to-next-note-title": "次のノートに移動",
"search-notes-title": "検索ノート",
"jump-to-note-title": "ジャンプ先…",
"calendar-title": "カレンダー",
"protected-session-title": "保護されたセッション",
"sync-status-title": "同期状態",
"settings-title": "設定",
"llm-chat-title": "ノートとチャット",
"options-title": "オプション",
"multi-factor-authentication-title": "多要素認証",
"etapi-title": "ETAPI"
}, },
"notes": { "notes": {
"new-note": "新しいノート", "new-note": "新しいノート",
@@ -296,7 +376,10 @@
"site-theme": "サイトのテーマ", "site-theme": "サイトのテーマ",
"search_placeholder": "検索...", "search_placeholder": "検索...",
"last-updated": "最終更新日 {{- date}}", "last-updated": "最終更新日 {{- date}}",
"subpages": "サブページ:" "subpages": "サブページ:",
"image_alt": "記事画像",
"on-this-page": "このページの内容",
"expand": "展開"
}, },
"hidden_subtree_templates": { "hidden_subtree_templates": {
"text-snippet": "テキストスニペット", "text-snippet": "テキストスニペット",
@@ -315,6 +398,19 @@
"board_note_second": "2番目のート", "board_note_second": "2番目のート",
"board_note_third": "3番目のート", "board_note_third": "3番目のート",
"board_status_progress": "進行中", "board_status_progress": "進行中",
"board_status_done": "完了" "board_status_done": "完了",
} "geo-map": "ジオマップ",
"geolocation": "ジオロケーション",
"built-in-templates": "内蔵のテンプレート",
"board_status_todo": "未完了"
},
"share_404": {
"title": "該当なし",
"heading": "該当なし"
},
"share_page": {
"clipped-from": "このノートは元々{{- url}}から切り取られたものです",
"no-content": "このノートには内容がありません。"
},
"weekdayNumber": "第{weekNumber}週"
} }

View File

@@ -1 +1,34 @@
{} {
"keyboard_actions": {
"open-command-palette": "Открыть палитру команд",
"quick-search": "Активировать панель быстрого поиска",
"move-note-up": "Переместить заметку вверх",
"move-note-down": "Переместить заметку вниз",
"move-note-up-in-hierarchy": "Переместить заметку вверх в иерархии",
"move-note-down-in-hierarchy": "Переместить заметку вниз в иерархии",
"delete-note": "Удалить заметку",
"back-in-note-history": "Перейти к предыдущей заметке в истории",
"forward-in-note-history": "Перейти к следующей заметке в истории",
"scroll-to-active-note": "Прокрутить дерево заметок до активной заметки",
"search-in-subtree": "Искать заметки в поддереве активной заметки",
"expand-subtree": "Развернуть поддерево текущей заметки",
"collapse-tree": "Свернуть дерево заметок полностью",
"collapse-subtree": "Свернуть поддерево текущей заметки",
"sort-child-notes": "Сортировка дочерних заметок",
"creating-and-moving-notes": "Создание и перемещение заметок",
"create-note-after": "Создать заметку после активной заметки",
"create-note-into": "Создать заметку как дочернюю для активной заметки",
"edit-note-title": "Перейти от дерева к деталям заметки и отредактировать заголовок",
"clone-notes-to": "Клонировать выбранные заметки",
"move-notes-to": "Переместить выбранные заметки",
"note-clipboard": "Буфер обмена заметок",
"copy-notes-to-clipboard": "Скопировать выбранные заметки в буфер обмена",
"paste-notes-from-clipboard": "Вставить заметки из буфера обмена в активную заметку",
"cut-notes-to-clipboard": "Вырезать выбранные заметки в буфер обмена",
"duplicate-subtree": "Дублировать поддерево",
"tabs-and-windows": "Вкладки и окна",
"open-new-tab": "Открыть новую вкладку",
"close-active-tab": "Закрыть активную вкладку",
"reopen-last-tab": "Повторно открыть последнюю закрытую вкладку"
}
}

View File

@@ -1,428 +1,428 @@
{ {
"keyboard_actions": { "keyboard_actions": {
"open-jump-to-note-dialog": "打開「跳轉筆記」對話方塊", "open-jump-to-note-dialog": "打開「跳轉筆記」對話方塊",
"search-in-subtree": "在目前筆記的子階層中搜尋筆記", "search-in-subtree": "在目前筆記的子階層中搜尋筆記",
"expand-subtree": "展開目前筆記的子階層", "expand-subtree": "展開目前筆記的子階層",
"collapse-tree": "收全部的筆記樹", "collapse-tree": "收全部的筆記樹",
"collapse-subtree": "收目前筆記的子階層", "collapse-subtree": "收目前筆記的子階層",
"sort-child-notes": "排序子筆記", "sort-child-notes": "排序子筆記",
"creating-and-moving-notes": "新增和移動筆記", "creating-and-moving-notes": "新增和移動筆記",
"create-note-into-inbox": "在收件匣(如果已定義)或日記中新增筆記", "create-note-into-inbox": "在收件匣(如果已定義)或日記中新增筆記",
"delete-note": "刪除筆記", "delete-note": "刪除筆記",
"move-note-up": "上移筆記", "move-note-up": "上移筆記",
"move-note-down": "下移筆記", "move-note-down": "下移筆記",
"move-note-up-in-hierarchy": "將筆記層級上移", "move-note-up-in-hierarchy": "將筆記層級上移",
"move-note-down-in-hierarchy": "將筆記層級下移", "move-note-down-in-hierarchy": "將筆記層級下移",
"edit-note-title": "從筆記樹跳轉筆記詳情並編輯標題", "edit-note-title": "從筆記樹跳轉筆記內容並編輯標題",
"edit-branch-prefix": "顯示編輯分支前綴對話方塊", "edit-branch-prefix": "顯示編輯分支前綴對話方塊",
"note-clipboard": "筆記剪貼簿", "note-clipboard": "筆記剪貼簿",
"copy-notes-to-clipboard": "複製選的筆記剪貼簿", "copy-notes-to-clipboard": "複製選的筆記剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中", "paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中",
"cut-notes-to-clipboard": "剪下選的筆記至剪貼簿", "cut-notes-to-clipboard": "剪下選的筆記至剪貼簿",
"select-all-notes-in-parent": "選擇前筆記級別的所有筆記", "select-all-notes-in-parent": "選擇前筆記級別的所有筆記",
"add-note-above-to-the-selection": "加入上方筆記至選擇中", "add-note-above-to-the-selection": "加入上方筆記至選擇中",
"add-note-below-to-selection": "加入下方筆記至選擇中", "add-note-below-to-selection": "加入下方筆記至選擇中",
"duplicate-subtree": "複製子階層", "duplicate-subtree": "複製子階層",
"tabs-and-windows": "分頁和視窗", "tabs-and-windows": "分頁和視窗",
"open-new-tab": "打開新分頁", "open-new-tab": "打開新分頁",
"close-active-tab": "關閉活動分頁", "close-active-tab": "關閉使用中分頁",
"reopen-last-tab": "重新打開最後關閉的分頁", "reopen-last-tab": "重新打開最後關閉的分頁",
"activate-next-tab": "切換至右側分頁", "activate-next-tab": "切換至右側分頁",
"activate-previous-tab": "切換至左側分頁", "activate-previous-tab": "切換至左側分頁",
"open-new-window": "打開新空白視窗", "open-new-window": "打開新空白視窗",
"toggle-tray": "從系統顯示/隱藏應用程式", "toggle-tray": "從系統顯示 / 隱藏應用程式",
"first-tab": "切換至列表中第一個分頁", "first-tab": "切換至列表中第一個分頁",
"second-tab": "切換至列表中第二個分頁", "second-tab": "切換至列表中第二個分頁",
"third-tab": "切換至列表中第三個分頁", "third-tab": "切換至列表中第三個分頁",
"fourth-tab": "切換至列表中第四個分頁", "fourth-tab": "切換至列表中第四個分頁",
"fifth-tab": "切換至列表中第五個分頁", "fifth-tab": "切換至列表中第五個分頁",
"sixth-tab": "切換至列表中第六個分頁", "sixth-tab": "切換至列表中第六個分頁",
"seventh-tab": "切換至列表中第七個分頁", "seventh-tab": "切換至列表中第七個分頁",
"eight-tab": "切換至列表中第八個分頁", "eight-tab": "切換至列表中第八個分頁",
"ninth-tab": "切換至列表中第九個分頁", "ninth-tab": "切換至列表中第九個分頁",
"last-tab": "切換至列表中最後一個分頁", "last-tab": "切換至列表中最後一個分頁",
"dialogs": "對話方塊", "dialogs": "對話方塊",
"show-note-source": "顯示筆記來源對話方塊", "show-note-source": "顯示筆記來源對話方塊",
"show-options": "打開選項頁面", "show-options": "打開選項頁面",
"show-revisions": "顯示筆記修改歷史對話方塊", "show-revisions": "顯示筆記修改歷史對話方塊",
"show-recent-changes": "顯示最近更改對話方塊", "show-recent-changes": "顯示最近更改對話方塊",
"show-sql-console": "顯示 SQL 控制台對話方塊", "show-sql-console": "打開 SQL 控制台頁面",
"show-backend-log": "顯示後端日誌對話方塊", "show-backend-log": "打開後端日誌頁面",
"text-note-operations": "文字筆記操作", "text-note-operations": "文字筆記操作",
"add-link-to-text": "打開對話方塊以插入連結", "add-link-to-text": "打開對話方塊以插入連結",
"follow-link-under-cursor": "開啟游標處的連結", "follow-link-under-cursor": "開啟游標處的連結",
"insert-date-and-time-to-text": "插入目前日期和時間", "insert-date-and-time-to-text": "插入目前日期和時間",
"paste-markdown-into-text": "將剪貼簿中的 Markdown 文字貼上", "paste-markdown-into-text": "將剪貼簿中的 Markdown 文字貼上",
"cut-into-note": "從目前筆記剪下選擇的部分並新增至子筆記", "cut-into-note": "從目前筆記剪下選擇的部分並新增至子筆記",
"add-include-note-to-text": "打開對話方塊以包含筆記", "add-include-note-to-text": "打開對話方塊以內嵌筆記",
"edit-readonly-note": "編輯唯讀筆記", "edit-readonly-note": "編輯唯讀筆記",
"attributes-labels-and-relations": "屬性(標籤和關係)", "attributes-labels-and-relations": "屬性(標籤和關係)",
"add-new-label": "新增新標籤", "add-new-label": "新增新標籤",
"create-new-relation": "新增新關", "create-new-relation": "新增新關",
"ribbon-tabs": "功能區分頁", "ribbon-tabs": "功能區分頁",
"toggle-basic-properties": "顯示基本屬性", "toggle-basic-properties": "顯示基本屬性",
"toggle-file-properties": "顯示文件屬性", "toggle-file-properties": "顯示檔案屬性",
"toggle-image-properties": "顯示圖像屬性", "toggle-image-properties": "顯示圖像屬性",
"toggle-owned-attributes": "顯示擁有的屬性", "toggle-owned-attributes": "顯示自有屬性",
"toggle-inherited-attributes": "顯示繼承屬性", "toggle-inherited-attributes": "顯示繼承屬性",
"toggle-promoted-attributes": "顯示升的屬性", "toggle-promoted-attributes": "顯示升的屬性",
"toggle-link-map": "顯示連結地圖", "toggle-link-map": "顯示連結地圖",
"toggle-note-info": "顯示筆記資訊", "toggle-note-info": "顯示筆記資訊",
"toggle-note-paths": "顯示筆記路徑", "toggle-note-paths": "顯示筆記路徑",
"toggle-similar-notes": "顯示相似筆記", "toggle-similar-notes": "顯示相似筆記",
"other": "其他", "other": "其他",
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮", "toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
"print-active-note": "列印目前筆記", "print-active-note": "列印目前筆記",
"open-note-externally": "以預設應用程式打開筆記文件", "open-note-externally": "以預設應用程式打開筆記檔案",
"render-active-note": "渲染(重新渲染)目前筆記", "render-active-note": "渲染(重新渲染)目前筆記",
"run-active-note": "執行目前的 JavaScript前端/後端)程式碼筆記", "run-active-note": "執行目前的 JavaScript前端 / 後端)程式碼筆記",
"toggle-note-hoisting": "提升目前筆記", "toggle-note-hoisting": "聚焦目前筆記",
"unhoist": "從任何地方取消提升", "unhoist": "取消任何聚焦",
"reload-frontend-app": "重新載入前端應用", "reload-frontend-app": "重新載入前端應用",
"open-dev-tools": "打開開發者工具", "open-dev-tools": "打開開發者工具",
"toggle-left-note-tree-panel": "顯示左側(筆記樹)面板", "toggle-left-note-tree-panel": "顯示左側(筆記樹)面板",
"toggle-full-screen": "切換全螢幕", "toggle-full-screen": "切換全螢幕",
"zoom-out": "縮小", "zoom-out": "縮小",
"zoom-in": "放大", "zoom-in": "放大",
"note-navigation": "筆記導航", "note-navigation": "筆記導航",
"reset-zoom-level": "重設縮放比例", "reset-zoom-level": "重設縮放比例",
"copy-without-formatting": "以純文字複製選文字", "copy-without-formatting": "以純文字複製選文字",
"force-save-revision": "強制新增/儲存目前筆記的新版本", "force-save-revision": "強制新增 / 儲存目前筆記的新版本",
"show-help": "顯示用戶指南", "show-help": "顯示用戶說明",
"toggle-book-properties": "顯示書籍屬性", "toggle-book-properties": "顯示集合屬性",
"back-in-note-history": "跳轉至歷史記錄中的上一個筆記", "back-in-note-history": "跳轉至歷史記錄中的上一個筆記",
"forward-in-note-history": "跳轉至歷史記錄中的下一個筆記", "forward-in-note-history": "跳轉至歷史記錄中的下一個筆記",
"open-command-palette": "打開命令面板", "open-command-palette": "打開命令面板",
"scroll-to-active-note": "動筆記樹目前筆記", "scroll-to-active-note": "動筆記樹目前筆記",
"quick-search": "開啟快速搜尋列", "quick-search": "開啟快速搜尋列",
"create-note-after": "新增筆記於目前筆記之後", "create-note-after": "新增筆記於目前筆記之後",
"create-note-into": "新增目前筆記的子筆記", "create-note-into": "新增目前筆記的子筆記",
"clone-notes-to": "複製選定筆記的複本至", "clone-notes-to": "克隆所選的筆記至",
"move-notes-to": "移動選的筆記至", "move-notes-to": "移動選的筆記至",
"show-cheatsheet": "顯示常用鍵盤快捷鍵", "show-cheatsheet": "顯示常用鍵盤快捷鍵",
"find-in-text": "顯示搜尋面板", "find-in-text": "顯示搜尋面板",
"toggle-classic-editor-toolbar": "顯示固定工具列編輯器的格式分頁", "toggle-classic-editor-toolbar": "顯示固定工具列編輯器的格式分頁",
"export-as-pdf": "匯出目前筆記為 PDF", "export-as-pdf": "匯出目前筆記為 PDF",
"toggle-zen-mode": "啟用/禁用禪模式(極簡界面以專注編輯)" "toggle-zen-mode": "啟用 / 禁用禪模式(極簡界面以專注編輯)"
}, },
"login": { "login": {
"title": "登入", "title": "登入",
"heading": "Trilium 登入", "heading": "Trilium 登入",
"incorrect-password": "密碼不正確。請再試一次。", "incorrect-password": "密碼不正確。請再試一次。",
"password": "密碼", "password": "密碼",
"remember-me": "記住我", "remember-me": "記住我",
"button": "登入", "button": "登入",
"incorrect-totp": "TOTP 不正確。請再試一次。", "incorrect-totp": "TOTP 不正確。請再試一次。",
"sign_in_with_sso": "用 {{ ssoIssuerName }} 登入" "sign_in_with_sso": "用 {{ ssoIssuerName }} 登入"
}, },
"set_password": { "set_password": {
"title": "設定密碼", "title": "設定密碼",
"heading": "設定密碼", "heading": "設定密碼",
"description": "在由網頁開始使用 Trilium 之前,您需要先設定一個密碼並用此密碼登入。", "description": "在由網頁開始使用 Trilium 之前,您需要先設定一個密碼並用此密碼登入。",
"password": "密碼", "password": "密碼",
"password-confirmation": "確認密碼", "password-confirmation": "確認密碼",
"button": "設定密碼" "button": "設定密碼"
}, },
"javascript-required": "Trilium 需要啟用 JavaScript。", "javascript-required": "Trilium 需要啟用 JavaScript。",
"setup": { "setup": {
"heading": "Trilium 筆記設定", "heading": "Trilium 筆記設定",
"new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件", "new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件",
"sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步", "sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步",
"sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步", "sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步",
"next": "下一步", "next": "下一步",
"init-in-progress": "文件正在初始化", "init-in-progress": "文件正在初始化",
"redirecting": "您即將被重新導向至應用程式。", "redirecting": "您即將被重新導向至應用程式。",
"title": "設定" "title": "設定"
}, },
"setup_sync-from-desktop": { "setup_sync-from-desktop": {
"heading": "從桌面版同步", "heading": "從桌面版同步",
"description": "此設定需要從桌面版本啟動:", "description": "此設定需要從桌面版本啟動:",
"step1": "打開您的桌面版 TriliumNext 筆記。", "step1": "打開您的桌面版 Trilium 筆記。",
"step2": "從 Trilium 選單中,點擊「選項」。", "step2": "從 Trilium 選單中,點擊「選項」。",
"step3": "點擊「同步」類別。", "step3": "點擊「同步」類別。",
"step4": "將伺服器版網址更改為:{{- host}} 並點擊存。", "step4": "將伺服器版網址更改為:{{- host}} 並點擊存。",
"step5": "點擊「測試同步」以驗證連接是否成功。", "step5": "點擊「測試同步」以驗證連接是否成功。",
"step6": "完成這些步驟後,點擊 {{- link}}。", "step6": "完成這些步驟後,點擊 {{- link}}。",
"step6-here": "這裡" "step6-here": "這裡"
}, },
"setup_sync-from-server": { "setup_sync-from-server": {
"heading": "從伺服器同步", "heading": "從伺服器同步",
"instructions": "請在下方輸入 Trilium 伺服器網址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。", "instructions": "請在下方輸入 Trilium 伺服器網址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。",
"server-host": "Trilium 伺服器網址", "server-host": "Trilium 伺服器網址",
"server-host-placeholder": "https://<主機名稱>:<端口>", "server-host-placeholder": "https://<主機名稱>:<端口>",
"proxy-server": "代理伺服器(可選)", "proxy-server": "代理伺服器(可選)",
"proxy-server-placeholder": "https://<主機名稱>:<端口>", "proxy-server-placeholder": "https://<主機名稱>:<端口>",
"note": "注意:", "note": "注意:",
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)", "proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
"password": "密碼", "password": "密碼",
"password-placeholder": "密碼", "password-placeholder": "密碼",
"back": "返回", "back": "返回",
"finish-setup": "完成設定" "finish-setup": "完成設定"
}, },
"setup_sync-in-progress": { "setup_sync-in-progress": {
"heading": "同步", "heading": "正在同步",
"successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。", "successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
"outstanding-items": "未完成的同步項目:", "outstanding-items": "未完成的同步項目:",
"outstanding-items-default": "無" "outstanding-items-default": "無"
}, },
"share_404": { "share_404": {
"title": "未找到", "title": "未找到",
"heading": "未找到" "heading": "未找到"
}, },
"share_page": { "share_page": {
"parent": "父級:", "parent": "父級:",
"clipped-from": "此筆記最初自 {{- url}} 剪下", "clipped-from": "此筆記最初自 {{- url}} 剪下",
"child-notes": "子筆記:", "child-notes": "子筆記:",
"no-content": "此筆記沒有內容。" "no-content": "此筆記沒有內容。"
}, },
"weekdays": { "weekdays": {
"monday": "週一", "monday": "週一",
"tuesday": "週二", "tuesday": "週二",
"wednesday": "週三", "wednesday": "週三",
"thursday": "週四", "thursday": "週四",
"friday": "週五", "friday": "週五",
"saturday": "週六", "saturday": "週六",
"sunday": "週日" "sunday": "週日"
}, },
"months": { "months": {
"january": "一月", "january": "一月",
"february": "二月", "february": "二月",
"march": "三月", "march": "三月",
"april": "四月", "april": "四月",
"may": "五月", "may": "五月",
"june": "六月", "june": "六月",
"july": "七月", "july": "七月",
"august": "八月", "august": "八月",
"september": "九月", "september": "九月",
"october": "十月", "october": "十月",
"november": "十一月", "november": "十一月",
"december": "十二月" "december": "十二月"
}, },
"special_notes": { "special_notes": {
"search_prefix": "搜尋:" "search_prefix": "搜尋:"
}, },
"test_sync": { "test_sync": {
"not-configured": "尚未設定同步伺服器主機,請先設定同步。", "not-configured": "尚未設定同步伺服器主機,請先設定同步。",
"successful": "成功與同步伺服器握手,已開始同步。" "successful": "成功與同步伺服器握手,已開始同步。"
}, },
"keyboard_action_names": { "keyboard_action_names": {
"zoom-in": "放大", "zoom-in": "放大",
"reset-zoom-level": "重設縮放比例", "reset-zoom-level": "重設縮放比例",
"zoom-out": "縮小", "zoom-out": "縮小",
"copy-without-formatting": "以純文字複製", "copy-without-formatting": "以純文字複製",
"force-save-revision": "強制儲存修改版本", "force-save-revision": "強制儲存修改版本",
"back-in-note-history": "返回筆記歷史", "back-in-note-history": "返回筆記歷史",
"forward-in-note-history": "前進筆記歷史", "forward-in-note-history": "前進筆記歷史",
"jump-to-note": "跳轉至…", "jump-to-note": "跳轉至…",
"scroll-to-active-note": "滾動到目前筆記", "scroll-to-active-note": "捲動至目前筆記",
"quick-search": "快速搜尋", "quick-search": "快速搜尋",
"search-in-subtree": "在子階層中搜尋", "search-in-subtree": "在子階層中搜尋",
"expand-subtree": "展開子階層", "expand-subtree": "展開子階層",
"collapse-tree": "收筆記樹", "collapse-tree": "收筆記樹",
"collapse-subtree": "收子階層", "collapse-subtree": "收子階層",
"sort-child-notes": "排序子筆記", "sort-child-notes": "排序子筆記",
"create-note-after": "於後面新建筆記", "create-note-after": "於後面新建筆記",
"create-note-into": "新建筆記至", "create-note-into": "新建筆記至",
"create-note-into-inbox": "新建筆記至收件匣", "create-note-into-inbox": "新建筆記至收件匣",
"delete-notes": "刪除筆記", "delete-notes": "刪除筆記",
"move-note-up": "上移筆記", "move-note-up": "上移筆記",
"move-note-down": "下移筆記", "move-note-down": "下移筆記",
"move-note-up-in-hierarchy": "上移筆記階層", "move-note-up-in-hierarchy": "上移筆記階層",
"move-note-down-in-hierarchy": "下移筆記階層", "move-note-down-in-hierarchy": "下移筆記階層",
"edit-note-title": "編輯筆記標題", "edit-note-title": "編輯筆記標題",
"edit-branch-prefix": "編輯分支前綴", "edit-branch-prefix": "編輯分支前綴",
"clone-notes-to": "複製筆記至", "clone-notes-to": "克隆筆記至",
"move-notes-to": "移動筆記至", "move-notes-to": "移動筆記至",
"copy-notes-to-clipboard": "複製筆記至剪貼簿", "copy-notes-to-clipboard": "複製筆記至剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記", "paste-notes-from-clipboard": "從剪貼簿貼上筆記",
"cut-notes-to-clipboard": "剪下筆記至剪貼簿", "cut-notes-to-clipboard": "剪下筆記至剪貼簿",
"select-all-notes-in-parent": "選擇父階層所有筆記", "select-all-notes-in-parent": "選擇父階層所有筆記",
"add-note-above-to-selection": "加入上方筆記至選擇中", "add-note-above-to-selection": "加入上方筆記至選擇中",
"add-note-below-to-selection": "加入下方筆記至選擇中", "add-note-below-to-selection": "加入下方筆記至選擇中",
"duplicate-subtree": "複製子階層", "duplicate-subtree": "複製子階層",
"open-new-tab": "打開新分頁", "open-new-tab": "打開新分頁",
"close-active-tab": "關閉目前分頁", "close-active-tab": "關閉目前分頁",
"reopen-last-tab": "重新打開最後關閉的分頁", "reopen-last-tab": "重新打開最後關閉的分頁",
"activate-next-tab": "切換至下一分頁", "activate-next-tab": "切換至下一分頁",
"activate-previous-tab": "切換至上一分頁", "activate-previous-tab": "切換至上一分頁",
"open-new-window": "打開新視窗", "open-new-window": "打開新視窗",
"toggle-system-tray-icon": "顯示/隱藏系統圖示", "toggle-system-tray-icon": "顯示 / 隱藏系統圖示",
"toggle-zen-mode": "啟用/禁用禪模式", "toggle-zen-mode": "啟用 / 禁用禪模式",
"switch-to-first-tab": "切換至第一個分頁", "switch-to-first-tab": "切換至第一個分頁",
"switch-to-second-tab": "切換至第二個分頁", "switch-to-second-tab": "切換至第二個分頁",
"switch-to-third-tab": "切換至第三個分頁", "switch-to-third-tab": "切換至第三個分頁",
"switch-to-fourth-tab": "切換至第四個分頁", "switch-to-fourth-tab": "切換至第四個分頁",
"switch-to-fifth-tab": "切換至第五個分頁", "switch-to-fifth-tab": "切換至第五個分頁",
"switch-to-sixth-tab": "切換至第六個分頁", "switch-to-sixth-tab": "切換至第六個分頁",
"switch-to-seventh-tab": "切換至第七個分頁", "switch-to-seventh-tab": "切換至第七個分頁",
"switch-to-eighth-tab": "切換至第八個分頁", "switch-to-eighth-tab": "切換至第八個分頁",
"switch-to-ninth-tab": "切換至第九個分頁", "switch-to-ninth-tab": "切換至第九個分頁",
"switch-to-last-tab": "切換至第最後一個分頁", "switch-to-last-tab": "切換至第最後一個分頁",
"show-note-source": "顯示筆記原始碼", "show-note-source": "顯示筆記原始碼",
"show-options": "顯示選項", "show-options": "顯示選項",
"show-revisions": "顯示修改歷史", "show-revisions": "顯示修改歷史",
"show-recent-changes": "顯示最近更改", "show-recent-changes": "顯示最近更改",
"show-sql-console": "顯示 SQL 控制台", "show-sql-console": "顯示 SQL 控制台",
"show-backend-log": "顯示後端日誌", "show-backend-log": "顯示後端日誌",
"show-help": "顯示幫助", "show-help": "顯示說明",
"show-cheatsheet": "顯示快捷鍵指南", "show-cheatsheet": "顯示快捷鍵指南",
"add-link-to-text": "插入連結", "add-link-to-text": "插入連結",
"follow-link-under-cursor": "開啟游標處的連結", "follow-link-under-cursor": "開啟游標處的連結",
"insert-date-and-time-to-text": "插入日期和時間", "insert-date-and-time-to-text": "插入日期和時間",
"paste-markdown-into-text": "貼上 Markdown 文字", "paste-markdown-into-text": "貼上 Markdown 文字",
"cut-into-note": "剪下至筆記", "cut-into-note": "剪下至筆記",
"add-include-note-to-text": "添加包含筆記", "add-include-note-to-text": "新增內嵌筆記",
"edit-read-only-note": "編輯唯讀筆記", "edit-read-only-note": "編輯唯讀筆記",
"add-new-label": "新增標籤", "add-new-label": "新增標籤",
"add-new-relation": "新增關", "add-new-relation": "新增關",
"toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器", "toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器",
"toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性", "toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性",
"toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性", "toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性",
"toggle-ribbon-tab-file-properties": "顯示功能區分頁:文件屬性", "toggle-ribbon-tab-file-properties": "顯示功能區分頁:檔案屬性",
"toggle-ribbon-tab-image-properties": "顯示功能區分頁:圖片屬性", "toggle-ribbon-tab-image-properties": "顯示功能區分頁:圖片屬性",
"toggle-ribbon-tab-owned-attributes": "顯示功能區分頁:自有屬性", "toggle-ribbon-tab-owned-attributes": "顯示功能區分頁:自有屬性",
"toggle-ribbon-tab-inherited-attributes": "顯示功能區分頁:繼承屬性", "toggle-ribbon-tab-inherited-attributes": "顯示功能區分頁:繼承屬性",
"toggle-ribbon-tab-promoted-attributes": "顯示功能區分頁:升屬性", "toggle-ribbon-tab-promoted-attributes": "顯示功能區分頁:升屬性",
"toggle-ribbon-tab-note-map": "顯示功能區分頁:筆記地圖", "toggle-ribbon-tab-note-map": "顯示功能區分頁:筆記地圖",
"toggle-ribbon-tab-note-info": "顯示功能區分頁:筆記資訊", "toggle-ribbon-tab-note-info": "顯示功能區分頁:筆記資訊",
"toggle-ribbon-tab-note-paths": "顯示功能區分頁:筆記路徑", "toggle-ribbon-tab-note-paths": "顯示功能區分頁:筆記路徑",
"toggle-ribbon-tab-similar-notes": "顯示功能區分頁:相似筆記", "toggle-ribbon-tab-similar-notes": "顯示功能區分頁:相似筆記",
"toggle-right-pane": "打開右側面板", "toggle-right-pane": "打開右側面板",
"print-active-note": "列印目前筆記", "print-active-note": "列印目前筆記",
"export-active-note-as-pdf": "匯出目前筆記為 PDF", "export-active-note-as-pdf": "匯出目前筆記為 PDF",
"open-note-externally": "於外部打開筆記", "open-note-externally": "於外部打開筆記",
"render-active-note": "渲染目前筆記", "render-active-note": "渲染目前筆記",
"run-active-note": "執行目前筆記", "run-active-note": "執行目前筆記",
"toggle-note-hoisting": "提升筆記", "toggle-note-hoisting": "聚焦筆記",
"unhoist-note": "取消提升筆記", "unhoist-note": "取消聚焦筆記",
"reload-frontend-app": "重新載入前端程式", "reload-frontend-app": "重新載入前端程式",
"open-developer-tools": "打開開發者工具", "open-developer-tools": "打開開發者工具",
"find-in-text": "在文字中尋找", "find-in-text": "在文字中尋找",
"toggle-left-pane": "打開左側面板", "toggle-left-pane": "打開左側面板",
"toggle-full-screen": "切換全螢幕", "toggle-full-screen": "切換全螢幕",
"command-palette": "命令面板" "command-palette": "命令面板"
}, },
"weekdayNumber": "第 {weekNumber} 週", "weekdayNumber": "第 {weekNumber} 週",
"quarterNumber": "第 {quarterNumber} 季度", "quarterNumber": "第 {quarterNumber} 季度",
"hidden-subtree": { "hidden-subtree": {
"root-title": "隱藏的筆記", "root-title": "隱藏的筆記",
"search-history-title": "搜尋歷史", "search-history-title": "搜尋歷史",
"note-map-title": "筆記地圖", "note-map-title": "筆記地圖",
"sql-console-history-title": "SQL 控制台歷史", "sql-console-history-title": "SQL 控制台歷史",
"shared-notes-title": "分享筆記", "shared-notes-title": "分享筆記",
"bulk-action-title": "批次操作", "bulk-action-title": "批次操作",
"backend-log-title": "後端日誌", "backend-log-title": "後端日誌",
"user-hidden-title": "隱藏的用戶", "user-hidden-title": "隱藏的用戶",
"launch-bar-templates-title": "啟動模版", "launch-bar-templates-title": "啟動模版",
"base-abstract-launcher-title": "基礎摘要啟動器", "base-abstract-launcher-title": "基礎摘要啟動器",
"command-launcher-title": "命令啟動器", "command-launcher-title": "命令啟動器",
"note-launcher-title": "筆記啟動器", "note-launcher-title": "筆記啟動器",
"script-launcher-title": "腳本啟動器", "script-launcher-title": "腳本啟動器",
"built-in-widget-title": "內建小工具", "built-in-widget-title": "內建元件",
"spacer-title": "空白占位", "spacer-title": "分隔元件",
"custom-widget-title": "自定義小工具", "custom-widget-title": "自訂元件",
"launch-bar-title": "啟動", "launch-bar-title": "啟動",
"available-launchers-title": "可用啟動器", "available-launchers-title": "可用啟動器",
"go-to-previous-note-title": "跳轉前一筆記", "go-to-previous-note-title": "跳轉前一筆記",
"go-to-next-note-title": "跳轉後一筆記", "go-to-next-note-title": "跳轉後一筆記",
"new-note-title": "新增筆記", "new-note-title": "新增筆記",
"search-notes-title": "搜尋筆記", "search-notes-title": "搜尋筆記",
"jump-to-note-title": "跳轉至…", "jump-to-note-title": "跳轉至…",
"calendar-title": "日曆", "calendar-title": "日曆",
"recent-changes-title": "最近修改", "recent-changes-title": "最近修改",
"bookmarks-title": "書籤", "bookmarks-title": "書籤",
"open-today-journal-note-title": "打開今日日記筆記", "open-today-journal-note-title": "打開今日日記筆記",
"quick-search-title": "快速搜尋", "quick-search-title": "快速搜尋",
"protected-session-title": "受保護的作業階段", "protected-session-title": "受保護的作業階段",
"sync-status-title": "同步狀態", "sync-status-title": "同步狀態",
"settings-title": "設定", "settings-title": "設定",
"llm-chat-title": "與筆記聊天", "llm-chat-title": "與筆記聊天",
"options-title": "選項", "options-title": "選項",
"appearance-title": "外觀", "appearance-title": "外觀",
"shortcuts-title": "快捷鍵", "shortcuts-title": "快捷鍵",
"text-notes": "文字筆記", "text-notes": "文字筆記",
"code-notes-title": "程式碼筆記", "code-notes-title": "程式碼筆記",
"images-title": "圖片", "images-title": "圖片",
"spellcheck-title": "拼寫檢查", "spellcheck-title": "拼寫檢查",
"password-title": "密碼", "password-title": "密碼",
"multi-factor-authentication-title": "多重身份驗證", "multi-factor-authentication-title": "多重身份驗證",
"etapi-title": "ETAPI", "etapi-title": "ETAPI",
"backup-title": "備份", "backup-title": "備份",
"sync-title": "同步", "sync-title": "同步",
"ai-llm-title": "AI/LLM", "ai-llm-title": "AI / LLM",
"other": "其他", "other": "其他",
"advanced-title": "進階", "advanced-title": "進階",
"visible-launchers-title": "可見啟動器", "visible-launchers-title": "可見啟動器",
"user-guide": "使用指南", "user-guide": "用戶說明",
"localization": "語言和區域", "localization": "語言和區域",
"inbox-title": "收件匣" "inbox-title": "收件匣"
}, },
"notes": { "notes": {
"new-note": "新增筆記", "new-note": "新增筆記",
"duplicate-note-suffix": "(重複)", "duplicate-note-suffix": "(重複)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}" "duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
}, },
"backend_log": { "backend_log": {
"log-does-not-exist": "後端日誌文件 '{{ fileName }}' 暫不存在。", "log-does-not-exist": "後端日誌文件 '{{ fileName }}' 暫不存在。",
"reading-log-failed": "讀取後端日誌文件 '{{ fileName }}' 失敗。" "reading-log-failed": "讀取後端日誌文件 '{{ fileName }}' 失敗。"
}, },
"content_renderer": { "content_renderer": {
"note-cannot-be-displayed": "無法顯示此類型筆記。" "note-cannot-be-displayed": "無法顯示此類型筆記。"
}, },
"pdf": { "pdf": {
"export_filter": "PDF 文件 (*.pdf)", "export_filter": "PDF 文件 (*.pdf)",
"unable-to-export-message": "目前筆記無法被匯出為 PDF 。", "unable-to-export-message": "目前筆記無法被匯出為 PDF 。",
"unable-to-export-title": "無法匯出為 PDF", "unable-to-export-title": "無法匯出為 PDF",
"unable-to-save-message": "選定文件無法被寫入。請重試或選擇其他路徑。" "unable-to-save-message": "所選檔案無法被寫入。請重試或選擇其他路徑。"
}, },
"tray": { "tray": {
"tooltip": "Trilium 筆記", "tooltip": "Trilium 筆記",
"close": "退出 Trilium", "close": "退出 Trilium",
"recents": "最近筆記", "recents": "最近筆記",
"bookmarks": "書籤", "bookmarks": "書籤",
"today": "打開今日日記筆記", "today": "打開今日日記筆記",
"new-note": "新增筆記", "new-note": "新增筆記",
"show-windows": "顯示視窗", "show-windows": "顯示視窗",
"open_new_window": "打開新視窗" "open_new_window": "打開新視窗"
}, },
"migration": { "migration": {
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。", "old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}", "error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。" "wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
}, },
"modals": { "modals": {
"error_title": "錯誤" "error_title": "錯誤"
}, },
"share_theme": { "share_theme": {
"site-theme": "網站主題", "site-theme": "網站主題",
"search_placeholder": "搜尋…", "search_placeholder": "搜尋…",
"image_alt": "文章圖片", "image_alt": "文章圖片",
"last-updated": "最近於 {{- date}} 更新", "last-updated": "最近於 {{- date}} 更新",
"subpages": "子頁面:", "subpages": "子頁面:",
"on-this-page": "本頁內容", "on-this-page": "本頁內容",
"expand": "展開" "expand": "展開"
}, },
"hidden_subtree_templates": { "hidden_subtree_templates": {
"text-snippet": "文字片段", "text-snippet": "文字片段",
"description": "描述", "description": "描述",
"list-view": "列表顯示", "list-view": "列表顯示",
"grid-view": "網格顯示", "grid-view": "網格顯示",
"calendar": "日曆", "calendar": "日曆",
"table": "表格", "table": "表格",
"geo-map": "地理地圖", "geo-map": "地理地圖",
"start-date": "開始日期", "start-date": "開始日期",
"end-date": "結束日期", "end-date": "結束日期",
"start-time": "開始時間", "start-time": "開始時間",
"end-time": "結束時間", "end-time": "結束時間",
"geolocation": "地理位置", "geolocation": "地理位置",
"built-in-templates": "內建模版", "built-in-templates": "內建模版",
"board": "看板", "board": "看板",
"status": "狀態", "status": "狀態",
"board_note_first": "第一個筆記", "board_note_first": "第一個筆記",
"board_note_second": "第二個筆記", "board_note_second": "第二個筆記",
"board_note_third": "第三個筆記", "board_note_third": "第三個筆記",
"board_status_todo": "待辦", "board_status_todo": "待辦",
"board_status_progress": "進行中", "board_status_progress": "進行中",
"board_status_done": "已完成" "board_status_done": "已完成"
} }
} }

View File

@@ -0,0 +1,12 @@
{
"keyboard_actions": {
"back-in-note-history": "Перейти до минулої нотатки в історії",
"forward-in-note-history": "Перейти до наступної нотатки в історії",
"open-command-palette": "Відкрити палітру команд",
"scroll-to-active-note": "Прокрутити дерево нотаток до активної нотатки",
"quick-search": "Показати панель швидкого пошуку",
"search-in-subtree": "Пошук нотаток в піддереві активної нотатки",
"expand-subtree": "Розкрити піддерево поточної нотатки",
"collapse-tree": "Згорнути все дерево нотаток"
}
}

View File

@@ -19,7 +19,8 @@ const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale
"ku": () => import("dayjs/locale/ku.js"), "ku": () => import("dayjs/locale/ku.js"),
"ro": () => import("dayjs/locale/ro.js"), "ro": () => import("dayjs/locale/ro.js"),
"ru": () => import("dayjs/locale/ru.js"), "ru": () => import("dayjs/locale/ru.js"),
"tw": () => import("dayjs/locale/zh-tw.js") "tw": () => import("dayjs/locale/zh-tw.js"),
"ja": () => import("dayjs/locale/ja.js")
} }
export async function initializeTranslations() { export async function initializeTranslations() {

View File

@@ -38,6 +38,9 @@ async function cleanupOldLogFiles() {
const customRetentionDays = config.Logging.retentionDays; const customRetentionDays = config.Logging.retentionDays;
if (customRetentionDays > 0) { if (customRetentionDays > 0) {
retentionDays = customRetentionDays; retentionDays = customRetentionDays;
} else if (customRetentionDays <= -1){
info(`Log cleanup: keeping all log files, as specified by configuration.`);
return
} }
const cutoffDate = new Date(); const cutoffDate = new Date();

1
docs/CNAME vendored Normal file
View File

@@ -0,0 +1 @@
triliumnotes.org

197
docs/README-ZH_CN.md vendored
View File

@@ -1,97 +1,178 @@
# Trilium Notes # Trilium Notes
[English](../README.md) | [Chinese](./README-ZH_CN.md) | [Russian](./README.ru.md) | [Japanese](./README.ja.md) | [Italian](./README.it.md) | [Spanish](./README.es.md) ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
Trilium Notes 是一个层次化的笔记应用程序,专注于建立大型个人知识库。请参阅[屏幕截图](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)以快速了解: [英文](../README.md) | [简体中文](./README-ZH_CN.md) | [正体中文](./README-ZH_TW.md) | [俄文](./README.ru.md) | [日文](./README.ja.md) | [意大利文](./README.it.md) | [西班牙文](./README.es.md)
Trilium Notes 是一款免费且开源、跨平台的阶层式笔记应用程序,专注于建立大型个人知识库。
想快速了解,请查看[屏幕截图](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./app.png" alt="Trilium Screenshot" width="1000"></a> <a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./app.png" alt="Trilium Screenshot" width="1000"></a>
## ⚠️ 为什么选择TriliumNext ## 🎁 功能
[原始的Trilium项目目前处于维护模式](https://github.com/zadam/trilium/issues/4620) * 笔记可组织成任意深度的树形结构。单一笔记可放在树中的多个位置(参见[笔记复制/克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes))。
* 丰富的所见即所得WYSIWYG笔记编辑器支持表格、图片与[数学公式](https://triliumnext.github.io/Docs/Wiki/text-notes),并具备 Markdown 的[自动格式](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)。
* 支持编辑[程序代码笔记](https://triliumnext.github.io/Docs/Wiki/code-notes),包含语法高亮。
* 快速、轻松地在笔记间[导航](https://triliumnext.github.io/Docs/Wiki/note-navigation)、全文搜索,以及[笔记聚焦hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)。
* 无缝的[笔记版本管理](https://triliumnext.github.io/Docs/Wiki/note-revisions)。
* 笔记[属性](https://triliumnext.github.io/Docs/Wiki/attributes)可用于笔记的组织、查询与高级[脚本](https://triliumnext.github.io/Docs/Wiki/scripts)。
* 接口提供英文、德文、西班牙文、法文、罗马尼亚文与中文(简体与正体)。
* 直接整合 [OpenID 与 TOTP](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) 以实现更安全的登录。
* 与自架的同步服务器进行[同步](https://triliumnext.github.io/Docs/Wiki/synchronization)
* 另有[第三方同步服务器托管服务](https://trilium.cc/paid-hosting)。
* 将笔记[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(公开发布)到互联网。
* 以每则笔记为粒度的强大[笔记加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)。
* 手绘/示意图:基于 [Excalidraw](https://excalidraw.com/)笔记类型为「canvas」
* 用于可视化笔记及其关系的[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)与[链接图](https://triliumnext.github.io/Docs/Wiki/link-map)。
* 思维导图:基于 [Mind Elixir](https://docs.mind-elixir.com/)。
* 具有定位钉与 GPX 轨迹的[地图](./User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md)。
* [脚本](https://triliumnext.github.io/Docs/Wiki/scripts)——参见[高级展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)。
* 用于自动化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)。
* 在可用性与效能上均可良好扩展,支持超过 100,000 笔笔记。
* 为手机与平板优化的[移动前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)。
* 内置[深色主题](https://triliumnext.github.io/Docs/Wiki/themes),并支持用户主题。
* [Evernote 导入](https://triliumnext.github.io/Docs/Wiki/evernote-import)与 [Markdown 导入与导出](https://triliumnext.github.io/Docs/Wiki/markdown)。
* 用于快速保存网页内容的 [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper)。
* 可自定义的 UI侧边栏按钮、用户自定义小组件等
* [度量指标Metrics](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md),并附有 [Grafana 仪表板](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)。
## 🗭 与我们讨论 ✨ 想要更多 TriliumNext 的主题、脚本、外挂与资源,亦可参考以下第三方资源/社群:
欢迎加入我们的官方讨论和社区。我们专注于Trilium的开发乐于听取您对功能、建议或问题的意见 - [awesome-trilium](https://github.com/Nriver/awesome-trilium)(第三方主题、脚本、外挂与更多)。
- [TriliumRocks!](https://trilium.rocks/)(教学、指南等等)。
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(用于同步讨论) ## ⚠️ 为什么是 TriliumNext
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions)(用于异步讨论)
- [Wiki](https://triliumnext.github.io/Docs/)(用于常见操作问题和用户指南)
上面链接的两个房间是镜像的所以您可以在任意平台上使用XMPP或者Matrix来和我们交流 [原本的 Trilium 项目目前处于维护模式](https://github.com/zadam/trilium/issues/4620)
### 非官方社区 ### 从 Trilium 迁移?
[Trilium Rocks](https://discord.gg/aqdX9mXX4r) 从既有的 zadam/Trilium 例项迁移到 TriliumNext/Notes 不需要特别的迁移步骤。只要[照一般方式安装 TriliumNext/Notes](#-安装),它就会直接使用你现有的数据库。
## 🎁 特性 版本至多至 [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) 与 zadam/trilium 最新版本 [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7) 兼容。之后的 TriliumNext 版本已提升同步版本号(与上述不再兼容)。
* 笔记可以排列成任意深的树。单个笔记可以放在树中的多个位置(请参阅[克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes) ## 📖 文件
* 丰富的所见即所得笔记编辑功能,包括带有 Markdown [自动格式化功能的](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)表格,图像和[数学公式](https://triliumnext.github.io/Docs/Wiki/text-notes#math-support)
* 支持编辑[使用源代码的笔记](https://triliumnext.github.io/Docs/Wiki/code-notes),包括语法高亮显示
* 笔记之间快速[导航](https://triliumnext.github.io/Docs/Wiki/note-navigation),全文搜索和[提升笔记](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* 无缝[笔记版本控制](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* 笔记[属性](https://triliumnext.github.io/Docs/Wiki/attributes)可用于笔记组织,查询和高级[脚本编写](https://triliumnext.github.io/Docs/Wiki/scripts)
* [同步](https://triliumnext.github.io/Docs/Wiki/synchronization)与自托管同步服务器
* 有一个[第三方提供的同步服务器托管服务](https://trilium.cc/paid-hosting)
* 公开地[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(发布)笔记到互联网
* 具有按笔记粒度的强大的[笔记加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)
* 使用自带的 Excalidraw 来绘制图表(笔记类型“画布”)
* [关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)和[链接图](https://triliumnext.github.io/Docs/Wiki/link-map),用于可视化笔记及其关系
* [脚本](https://triliumnext.github.io/Docs/Wiki/scripts) - 请参阅[高级功能展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* 可用于自动化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)
* 在拥有超过 10 万条笔记时仍能保持良好的可用性和性能
* 针对智能手机和平板电脑进行优化的[用于移动设备的前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)
* [夜间主题](https://triliumnext.github.io/Docs/Wiki/themes)
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) 和 [Markdown 导入导出](https://triliumnext.github.io/Docs/Wiki/markdown)功能
* 使用[网页剪藏](https://triliumnext.github.io/Docs/Wiki/web-clipper)轻松保存互联网上的内容
✨ 查看以下第三方资源获取更多关于TriliumNext的好东西 我们目前正将文件搬移至应用程序内(在 Trilium 中按 `F1`)。在完成前,文件中可能会有缺漏。如果你想在 GitHub 上查看,也可以直接查看[使用说明](./User%20Guide/User%20Guide/)。
- [awesome-trilium](https://github.com/Nriver/awesome-trilium):提供第三方主题、脚本、插件等资源的列表。 以下提供一些快速连结,方便你导览文件:
- [TriliumRocks!](https://trilium.rocks/):提供教程、指南等更多内容。 - [服务器安装](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker 安装](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [升级 TriliumNext](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [基本概念与功能-笔记](./User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [个人知识库的模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
## 🏗 构建 在我们完成重新整理文件架构之前,你也可以[查看旧版文件](https://triliumnext.github.io/Docs)。
Trilium 可以用作桌面应用程序Linux 和 Windows或服务器Linux上托管的 Web 应用程序。虽然有 macOS 版本的桌面应用程序,但它[不受支持](https://triliumnext.github.io/Docs/Wiki/faq#mac-os-support)。 ## 💬 与我们交流
* 如果要在桌面上使用 Trilium请从[最新版本](https://github.com/TriliumNext/Trilium/releases/latest)下载适用于您平台的二进制版本,解压缩该软件包并运行`trilium`可执行文件。 欢迎加入官方社群。我们很乐意听到你对功能、建议或问题的想法!
* 如果要在服务器上安装 Trilium请参考[此页面](https://triliumnext.github.io/Docs/Wiki/server-installation)。
* 当前仅支持(测试过)最近发布的 Chrome 和 Firefox 浏览器。
Trilium 也提供 Flatpak - [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步讨论)
- `General` Matrix 房间也桥接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [GitHub Discussions](https://github.com/TriliumNext/Notes/discussions)(异步讨论)。
- [GitHub Issues](https://github.com/TriliumNext/Notes/issues)(回报错误与提出功能需求)。
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium) ## 🏗 安装
## 📝 文档 ### Windows / macOS
[有关文档页面的完整列表,请参见 Wiki。](https://triliumnext.github.io/Docs/) [最新释出页面](https://github.com/TriliumNext/Trilium/releases/latest)下载你平台的二进制文件,解压缩后执行 `trilium` 可执行文件。
* [Wiki 的中文翻译版本](https://github.com/baddate/trilium/wiki/) ### Linux
您还可以阅读[个人知识库模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge),以获取有关如何使用 Trilium 的灵感 如果你的发行版如下表所列,请使用该发行版的套件
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
你也可以从[最新释出页面](https://github.com/TriliumNext/Trilium/releases/latest)下载对应平台的二进制文件,解压缩后执行 `trilium` 可执行文件。
TriliumNext 也提供 Flatpak惟尚未发布到 FlatHub。
### 查看器(任何操作系统)
若你有(如下所述的)服务器安装,便可直接存取网页界面(其与桌面应用几乎相同)。
目前仅支持(并实测)最新版的 Chrome 与 Firefox。
### 移动装置
若要在行动装置上使用 TriliumNext你可以透过移动查看器存取服务器安装的移动版接口见下
如果你偏好原生 Android 应用,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。回报问题或缺少的功能,请至[其储存库](https://github.com/FliegendeWurst/TriliumDroid)。
更多关于移动应用支持的信息请见议题https://github.com/TriliumNext/Notes/issues/72。
### 服务器
若要在你自己的服务器上安装 TriliumNext包括从 [Docker Hub](https://hub.docker.com/r/triliumnext/trilium) 使用 Docker 部署),请遵循[服务器安装文件](https://triliumnext.github.io/Docs/Wiki/server-installation)。
## 💻 贡献 ## 💻 贡献
### 翻译
或者克隆本仓库到本地,并运行 如果你是母语人士,欢迎前往我们的 [Weblate 页面](https://hosted.weblate.org/engage/trilium/)协助翻译 Trilium。
以下是目前的语言覆盖状态:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### 程序代码
下载储存库,使用 `pnpm` 安装相依套件,接着启动服务器(于 http://localhost:8080 提供服务):
```shell ```shell
npm install git clone https://github.com/TriliumNext/Trilium.git
npm run server:start cd Trilium
pnpm install
pnpm run server:start
``` ```
## 👏 致谢 ### 文件
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 市面上最好的所见即所得编辑器,拥有互动性强且聆听能力强的团队 下载储存库,使用 `pnpm` 安装相依套件,接着启动编辑文件所需的环境:
* [FancyTree](https://github.com/mar10/fancytree) - 一个非常丰富的关于树的库强大到没有对手。没有它Trilium Notes 将不会如此。 ```shell
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 支持大量语言的代码编辑器 git clone https://github.com/TriliumNext/Trilium.git
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 强大的可视化连接库。用于[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)和[链接图](https://triliumnext.github.io/Docs/Wiki/link-map) cd Trilium
pnpm install
pnpm nx run edit-docs:edit-docs
```
## 🤝 捐赠 ### 建置桌面可执行文件
你可以通过 GitHub Sponsors[PayPal](https://paypal.me/za4am) 或者比特币 (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2) 来捐赠。 下载储存库,使用 `pnpm` 安装相依套件,然后为 Windows 建置桌面应用:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
## 🔑 许可证 更多细节请参见[开发文件](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md)。
本程序是自由软件:你可以再发布本软件和/或修改本软件,只要你遵循 Free Software Foundation 发布的 GNU Affero General Public License 的第三版或者任何(由你选择)更晚的版本。 ### 开发者文件
请参阅[环境设定指南](./Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。若有更多疑问,欢迎透过上方「与我们交流」章节所列连结与我们联系。
## 👏 鸣谢
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) —— 业界最佳的所见即所得编辑器,团队互动积极。
* [FancyTree](https://github.com/mar10/fancytree) —— 功能非常丰富的树状元件几乎没有对手。没有它Trilium Notes 将不会是今天的样子。
* [CodeMirror](https://github.com/codemirror/CodeMirror) —— 支持大量语言的程序代码编辑器。
* [jsPlumb](https://github.com/jsplumb/jsplumb) —— 无可匹敌的视觉联机函式库。用于[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map.html)与[连结图](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)。
## 🤝 支持我们
目前尚无法直接赞助 TriliumNext 组织。不过你可以:
- 透过赞助我们的开发者来支持 TriliumNext 的持续开发:[eliandoran](https://github.com/sponsors/eliandoran)(完整清单请见 [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors)))
- 透过 [PayPal](https://paypal.me/za4am) 或比特币bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2向原始的 Trilium 开发者([zadam](https://github.com/sponsors/zadam))表达支持。
## 🔑 授权条款
Copyright 20172025 zadam、Elian Doran 与其他贡献者。
本程序系自由软件你可以在自由软件基金会Free Software Foundation所发布的 GNU Affero 通用公众授权条款GNU AGPL第 3 版或(由你选择)任何后续版本之条款下重新散布或修改本程序。

178
docs/README-ZH_TW.md vendored Normal file
View File

@@ -0,0 +1,178 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[英文](../README.md) | [簡體中文](./README-ZH_CN.md) | [正體中文](./README-ZH_TW.md) | [俄文](./README.ru.md) | [日文](./README.ja.md) | [義大利文](./README.it.md) | [西班牙文](./README.es.md)
Trilium Notes 是一款免費且開源、跨平台的階層式筆記應用程式,專注於建立大型個人知識庫。
想快速了解,請查看[螢幕截圖](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./app.png" alt="Trilium Screenshot" width="1000"></a>
## 🎁 功能
* 筆記可組織成任意深度的樹狀結構。單一筆記可放在樹中的多個位置(參見[筆記複製/克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes))。
* 豐富的所見即所得WYSIWYG筆記編輯器支援表格、圖片與[數學公式](https://triliumnext.github.io/Docs/Wiki/text-notes),並具備 Markdown 的[自動格式化](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)。
* 支援編輯[程式碼筆記](https://triliumnext.github.io/Docs/Wiki/code-notes),包含語法高亮。
* 快速、輕鬆地在筆記間[導航](https://triliumnext.github.io/Docs/Wiki/note-navigation)、全文搜尋,以及[筆記聚焦hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)。
* 無縫的[筆記版本管理](https://triliumnext.github.io/Docs/Wiki/note-revisions)。
* 筆記[屬性](https://triliumnext.github.io/Docs/Wiki/attributes)可用於筆記的組織、查詢與進階[腳本](https://triliumnext.github.io/Docs/Wiki/scripts)。
* 介面提供英文、德文、西班牙文、法文、羅馬尼亞文與中文(簡體與正體)。
* 直接整合 [OpenID 與 TOTP](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) 以實現更安全的登入。
* 與自架的同步伺服器進行[同步](https://triliumnext.github.io/Docs/Wiki/synchronization)
* 另有[第三方同步伺服器託管服務](https://trilium.cc/paid-hosting)。
* 將筆記[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(公開發布)到網際網路。
* 以每則筆記為粒度的強大[筆記加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)。
* 手繪/示意圖:基於 [Excalidraw](https://excalidraw.com/)筆記類型為「canvas」
* 用於視覺化筆記及其關係的[關聯圖](https://triliumnext.github.io/Docs/Wiki/relation-map)與[連結圖](https://triliumnext.github.io/Docs/Wiki/link-map)。
* 心智圖:基於 [Mind Elixir](https://docs.mind-elixir.com/)。
* 具有定位釘與 GPX 軌跡的[地圖](./User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md)。
* [腳本](https://triliumnext.github.io/Docs/Wiki/scripts)——參見[進階展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)。
* 用於自動化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)。
* 在可用性與效能上均可良好擴展,支援超過 100,000 筆筆記。
* 為手機與平板最佳化的[行動前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)。
* 內建[深色主題](https://triliumnext.github.io/Docs/Wiki/themes),並支援使用者主題。
* [Evernote 匯入](https://triliumnext.github.io/Docs/Wiki/evernote-import)與 [Markdown 匯入與匯出](https://triliumnext.github.io/Docs/Wiki/markdown)。
* 用於快速保存網頁內容的 [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper)。
* 可自訂的 UI側邊欄按鈕、使用者自訂小工具等
* [度量指標Metrics](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md),並附有 [Grafana 儀表板](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)。
✨ 想要更多 TriliumNext 的主題、腳本、外掛與資源,亦可參考以下第三方資源/社群:
- [awesome-trilium](https://github.com/Nriver/awesome-trilium)(第三方主題、腳本、外掛與更多)。
- [TriliumRocks!](https://trilium.rocks/)(教學、指南等等)。
## ⚠️ 為什麼是 TriliumNext
[原本的 Trilium 專案目前處於維護模式](https://github.com/zadam/trilium/issues/4620)。
### 從 Trilium 遷移?
從既有的 zadam/Trilium 例項遷移到 TriliumNext/Notes 不需要特別的遷移步驟。只要[照一般方式安裝 TriliumNext/Notes](#-安裝),它就會直接使用你現有的資料庫。
版本至多至 [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) 與 zadam/trilium 最新版本 [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7) 相容。之後的 TriliumNext 版本已提升同步版本號(與上述不再相容)。
## 📖 文件
我們目前正將文件搬移至應用程式內(在 Trilium 中按 `F1`)。在完成前,文件中可能會有缺漏。如果你想在 GitHub 上瀏覽,也可以直接查看[使用說明](./User%20Guide/User%20Guide/)。
以下提供一些快速連結,方便你導覽文件:
- [伺服器安裝](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker 安裝](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [升級 TriliumNext](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [基本概念與功能-筆記](./User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [個人知識庫的模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
在我們完成重新整理文件架構之前,你也可以[瀏覽舊版文件](https://triliumnext.github.io/Docs)。
## 💬 與我們交流
歡迎加入官方社群。我們很樂意聽到你對功能、建議或問題的想法!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步討論)
- `General` Matrix 房間也橋接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [GitHub Discussions](https://github.com/TriliumNext/Notes/discussions)(非同步討論)。
- [GitHub Issues](https://github.com/TriliumNext/Notes/issues)(回報錯誤與提出功能需求)。
## 🏗 安裝
### Windows / macOS
從[最新釋出頁面](https://github.com/TriliumNext/Trilium/releases/latest)下載你平台的二進位檔,解壓縮後執行 `trilium` 可執行檔。
### Linux
如果你的發行版如下表所列,請使用該發行版的套件。
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
你也可以從[最新釋出頁面](https://github.com/TriliumNext/Trilium/releases/latest)下載對應平台的二進位檔,解壓縮後執行 `trilium` 可執行檔。
TriliumNext 也提供 Flatpak惟尚未發佈到 FlatHub。
### 瀏覽器(任何作業系統)
若你有(如下所述的)伺服器安裝,便可直接存取網頁介面(其與桌面應用幾乎相同)。
目前僅支援(並實測)最新版的 Chrome 與 Firefox。
### 行動裝置
若要在行動裝置上使用 TriliumNext你可以透過行動瀏覽器存取伺服器安裝的行動版介面見下
如果你偏好原生 Android 應用,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。回報問題或缺少的功能,請至[其儲存庫](https://github.com/FliegendeWurst/TriliumDroid)。
更多關於行動應用支援的資訊請見議題https://github.com/TriliumNext/Notes/issues/72。
### 伺服器
若要在你自己的伺服器上安裝 TriliumNext包括從 [Docker Hub](https://hub.docker.com/r/triliumnext/trilium) 使用 Docker 部署),請遵循[伺服器安裝文件](https://triliumnext.github.io/Docs/Wiki/server-installation)。
## 💻 貢獻
### 翻譯
如果你是母語人士,歡迎前往我們的 [Weblate 頁面](https://hosted.weblate.org/engage/trilium/)協助翻譯 Trilium。
以下是目前的語言覆蓋狀態:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### 程式碼
下載儲存庫,使用 `pnpm` 安裝相依套件,接著啟動伺服器(將於 http://localhost:8080 提供服務):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
### 文件
下載儲存庫,使用 `pnpm` 安裝相依套件,接著啟動編輯文件所需的環境:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx run edit-docs:edit-docs
```
### 建置桌面可執行檔
下載儲存庫,使用 `pnpm` 安裝相依套件,然後為 Windows 建置桌面應用:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
更多細節請參見[開發文件](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md)。
### 開發者文件
請參閱[環境設定指南](./Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。若有更多疑問,歡迎透過上方「與我們交流」章節所列連結與我們聯繫。
## 👏 鳴謝
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) —— 業界最佳的所見即所得編輯器,團隊互動積極。
* [FancyTree](https://github.com/mar10/fancytree) —— 功能非常豐富的樹狀元件幾乎沒有對手。沒有它Trilium Notes 將不會是今天的樣子。
* [CodeMirror](https://github.com/codemirror/CodeMirror) —— 支援大量語言的程式碼編輯器。
* [jsPlumb](https://github.com/jsplumb/jsplumb) —— 無可匹敵的視覺連線函式庫。用於[關聯圖](https://triliumnext.github.io/Docs/Wiki/relation-map.html)與[連結圖](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)。
## 🤝 支援我們
目前尚無法直接贊助 TriliumNext 組織。不過你可以:
- 透過贊助我們的開發者來支持 TriliumNext 的持續開發:[eliandoran](https://github.com/sponsors/eliandoran)(完整清單請見 [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors)))
- 透過 [PayPal](https://paypal.me/za4am) 或比特幣bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2向原始的 Trilium 開發者([zadam](https://github.com/sponsors/zadam))表達支持。
## 🔑 授權條款
Copyright 20172025 zadam、Elian Doran 與其他貢獻者。
本程式係自由軟體你可以在自由軟體基金會Free Software Foundation所發佈的 GNU Affero 通用公眾授權條款GNU AGPL第 3 版或(由你選擇)任何後續版本之條款下重新散布或修改本程式。

14
docs/RPM-GPG-KEY-trilium vendored Normal file
View File

@@ -0,0 +1,14 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEaJ2E2RYJKwYBBAHaRw8BAQdAz7cpW/2YGtzJxkY1/ruwfDcVRv1sQQdjVrg3
u1YWhyq0NFRyaWxpdW0gTm90ZXMgU2lnbmluZyBLZXkgPHRyaWxpdW1ub3Rlc0Bv
dXRsb29rLmNvbT6ImQQTFgoAQRYhBJM5P1tHTrWujPnuCW11K/PW56WUBQJonYTZ
AhsDBQkFo5qABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEG11K/PW56WU
ND4BAN9Uhl8/g7GnIZuZU2M+HUVtrY5b9SBiCYBEHmaA4CiqAQCY5xynB8T7jM/Z
zRpMmSseDMYNHVvNjy3FvBe3D6YoDrg4BGidhNkSCisGAQQBl1UBBQEBB0C0i0Ns
8lCfDPY479cOgP9Yj1yaZAKyT6qsgzuznf1EGAMBCAeIfgQYFgoAJhYhBJM5P1tH
TrWujPnuCW11K/PW56WUBQJonYTZAhsMBQkFo5qAAAoJEG11K/PW56WUgpIA+gMn
BgzRQHqm8ttf5ry155l1JtuhIx/q6UjsgqO0L3aEAP9KbJ3Vh8+bcoXymskozrVm
yTglNDkROupmyJahcKlpBQ==
=g5kb
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -61,6 +61,32 @@
"attachments": [], "attachments": [],
"dirFileName": "Release Notes", "dirFileName": "Release Notes",
"children": [ "children": [
{
"isClone": false,
"noteId": "PLUoryywi0BC",
"notePath": [
"hD3V4hiu2VW4",
"PLUoryywi0BC"
],
"title": "v0.98.0",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "template",
"value": "wyurrlcDl416",
"isInheritable": false,
"position": 60
}
],
"format": "markdown",
"dataFileName": "v0.98.0.md",
"attachments": []
},
{ {
"isClone": false, "isClone": false,
"noteId": "lvOuiWsLDv8F", "noteId": "lvOuiWsLDv8F",
@@ -69,7 +95,7 @@
"lvOuiWsLDv8F" "lvOuiWsLDv8F"
], ],
"title": "v0.97.2", "title": "v0.97.2",
"notePosition": 10, "notePosition": 20,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -95,7 +121,7 @@
"OtFZ6Nd9vM3n" "OtFZ6Nd9vM3n"
], ],
"title": "v0.97.1", "title": "v0.97.1",
"notePosition": 20, "notePosition": 30,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -121,7 +147,7 @@
"SJZ5PwfzHSQ1" "SJZ5PwfzHSQ1"
], ],
"title": "v0.97.0", "title": "v0.97.0",
"notePosition": 30, "notePosition": 40,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -147,7 +173,7 @@
"mYXFde3LuNR7" "mYXFde3LuNR7"
], ],
"title": "v0.96.0", "title": "v0.96.0",
"notePosition": 40, "notePosition": 50,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -173,7 +199,7 @@
"jthwbL0FdaeU" "jthwbL0FdaeU"
], ],
"title": "v0.95.0", "title": "v0.95.0",
"notePosition": 50, "notePosition": 60,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -199,7 +225,7 @@
"7HGYsJbLuhnv" "7HGYsJbLuhnv"
], ],
"title": "v0.94.1", "title": "v0.94.1",
"notePosition": 60, "notePosition": 70,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -225,7 +251,7 @@
"Neq53ujRGBqv" "Neq53ujRGBqv"
], ],
"title": "v0.94.0", "title": "v0.94.0",
"notePosition": 70, "notePosition": 80,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -251,7 +277,7 @@
"VN3xnce1vLkX" "VN3xnce1vLkX"
], ],
"title": "v0.93.0", "title": "v0.93.0",
"notePosition": 80, "notePosition": 90,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -269,7 +295,7 @@
"WRaBfQqPr6qo" "WRaBfQqPr6qo"
], ],
"title": "v0.92.7", "title": "v0.92.7",
"notePosition": 90, "notePosition": 100,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -295,7 +321,7 @@
"a2rwfKNmUFU1" "a2rwfKNmUFU1"
], ],
"title": "v0.92.6", "title": "v0.92.6",
"notePosition": 100, "notePosition": 110,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -313,7 +339,7 @@
"fEJ8qErr0BKL" "fEJ8qErr0BKL"
], ],
"title": "v0.92.5-beta", "title": "v0.92.5-beta",
"notePosition": 110, "notePosition": 120,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -331,7 +357,7 @@
"kkkZQQGSXjwy" "kkkZQQGSXjwy"
], ],
"title": "v0.92.4", "title": "v0.92.4",
"notePosition": 120, "notePosition": 130,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -349,7 +375,7 @@
"vAroNixiezaH" "vAroNixiezaH"
], ],
"title": "v0.92.3-beta", "title": "v0.92.3-beta",
"notePosition": 130, "notePosition": 140,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -367,7 +393,7 @@
"mHEq1wxAKNZd" "mHEq1wxAKNZd"
], ],
"title": "v0.92.2-beta", "title": "v0.92.2-beta",
"notePosition": 140, "notePosition": 150,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -385,7 +411,7 @@
"IykjoAmBpc61" "IykjoAmBpc61"
], ],
"title": "v0.92.1-beta", "title": "v0.92.1-beta",
"notePosition": 150, "notePosition": 160,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -403,7 +429,7 @@
"dq2AJ9vSBX4Y" "dq2AJ9vSBX4Y"
], ],
"title": "v0.92.0-beta", "title": "v0.92.0-beta",
"notePosition": 160, "notePosition": 170,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -421,7 +447,7 @@
"3a8aMe4jz4yM" "3a8aMe4jz4yM"
], ],
"title": "v0.91.6", "title": "v0.91.6",
"notePosition": 170, "notePosition": 180,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -439,7 +465,7 @@
"8djQjkiDGESe" "8djQjkiDGESe"
], ],
"title": "v0.91.5", "title": "v0.91.5",
"notePosition": 180, "notePosition": 190,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -457,7 +483,7 @@
"OylxVoVJqNmr" "OylxVoVJqNmr"
], ],
"title": "v0.91.4-beta", "title": "v0.91.4-beta",
"notePosition": 190, "notePosition": 200,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -475,7 +501,7 @@
"tANGQDvnyhrj" "tANGQDvnyhrj"
], ],
"title": "v0.91.3-beta", "title": "v0.91.3-beta",
"notePosition": 200, "notePosition": 210,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -493,7 +519,7 @@
"hMoBfwSoj1SC" "hMoBfwSoj1SC"
], ],
"title": "v0.91.2-beta", "title": "v0.91.2-beta",
"notePosition": 210, "notePosition": 220,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -511,7 +537,7 @@
"a2XMSKROCl9z" "a2XMSKROCl9z"
], ],
"title": "v0.91.1-beta", "title": "v0.91.1-beta",
"notePosition": 220, "notePosition": 230,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -529,7 +555,7 @@
"yqXFvWbLkuMD" "yqXFvWbLkuMD"
], ],
"title": "v0.90.12", "title": "v0.90.12",
"notePosition": 230, "notePosition": 240,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -547,7 +573,7 @@
"veS7pg311yJP" "veS7pg311yJP"
], ],
"title": "v0.90.11-beta", "title": "v0.90.11-beta",
"notePosition": 240, "notePosition": 250,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -565,7 +591,7 @@
"sq5W9TQxRqMq" "sq5W9TQxRqMq"
], ],
"title": "v0.90.10-beta", "title": "v0.90.10-beta",
"notePosition": 250, "notePosition": 260,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -583,7 +609,7 @@
"yFEGVCUM9tPx" "yFEGVCUM9tPx"
], ],
"title": "v0.90.9-beta", "title": "v0.90.9-beta",
"notePosition": 260, "notePosition": 270,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -601,7 +627,7 @@
"o4wAGqOQuJtV" "o4wAGqOQuJtV"
], ],
"title": "v0.90.8", "title": "v0.90.8",
"notePosition": 270, "notePosition": 280,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -634,7 +660,7 @@
"i4A5g9iOg9I0" "i4A5g9iOg9I0"
], ],
"title": "v0.90.7-beta", "title": "v0.90.7-beta",
"notePosition": 280, "notePosition": 290,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -652,7 +678,7 @@
"ThNf2GaKgXUs" "ThNf2GaKgXUs"
], ],
"title": "v0.90.6-beta", "title": "v0.90.6-beta",
"notePosition": 290, "notePosition": 300,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -670,7 +696,7 @@
"G4PAi554kQUr" "G4PAi554kQUr"
], ],
"title": "v0.90.5-beta", "title": "v0.90.5-beta",
"notePosition": 300, "notePosition": 310,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -697,7 +723,7 @@
"zATRobGRCmBn" "zATRobGRCmBn"
], ],
"title": "v0.90.4", "title": "v0.90.4",
"notePosition": 310, "notePosition": 320,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -715,7 +741,7 @@
"sCDLf8IKn3Iz" "sCDLf8IKn3Iz"
], ],
"title": "v0.90.3", "title": "v0.90.3",
"notePosition": 320, "notePosition": 330,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -733,7 +759,7 @@
"VqqyBu4AuTjC" "VqqyBu4AuTjC"
], ],
"title": "v0.90.2-beta", "title": "v0.90.2-beta",
"notePosition": 330, "notePosition": 340,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -751,7 +777,7 @@
"RX3Nl7wInLsA" "RX3Nl7wInLsA"
], ],
"title": "v0.90.1-beta", "title": "v0.90.1-beta",
"notePosition": 340, "notePosition": 350,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -769,7 +795,7 @@
"GyueACukPWjk" "GyueACukPWjk"
], ],
"title": "v0.90.0-beta", "title": "v0.90.0-beta",
"notePosition": 350, "notePosition": 360,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",
@@ -787,7 +813,7 @@
"wyurrlcDl416" "wyurrlcDl416"
], ],
"title": "Release Template", "title": "Release Template",
"notePosition": 360, "notePosition": 370,
"prefix": null, "prefix": null,
"isExpanded": false, "isExpanded": false,
"type": "text", "type": "text",

View File

@@ -0,0 +1,63 @@
# v0.98.0
> [!IMPORTANT]
> If you enjoyed this release, consider showing a token of appreciation by:
>
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
## 💡 Key highlights
* [Enhanced Search with Fuzzy Matching and Better UX](https://github.com/TriliumNext/Trilium/pull/6536) by @perfectra1n
* For Linux users, RPM packages are now GPG-signed.
* The keys must be imported once per device, before installation: `sudo rpm --import https://triliumnotes.org/RPM-GPG-KEY-trilium`
## 🐞 Bugfixes
* The "chat with notes" icon re-appears on 0.97.2's Launchbar after update, even though the LLM feature was disabled before
* Board view: sub-children (recursive) not displayed.
* [Canvas: unable to open internal note path links](https://github.com/TriliumNext/Trilium/issues/6606)
* Delay when opening a Text note for the first time
* Search term is not highlighted in preview search result
* ETAPI: Save note revision if needed by @perfectra1n
* [No update notification in the global menu](https://github.com/TriliumNext/Trilium/pull/6657) by @SiriusXT
## ✨ Improvements
* Zen mode is now supported on mobile by @Papierkorb2292
* Code notes: new Cobalt2 theme by @hulmgulm
* Existing users will be prompted with a message at the start of the application (and an option to dismiss it permanently):
* To enable background effects on Windows, since it has become stable.
* To use the TriliumNext theme.
* The built-in themes were renamed:
* TriliumNext themes become simply Trilium
* The Light/Dark/Auto themes become Legacy
* [Clean up old backend logs](https://github.com/TriliumNext/Trilium/pull/6609) by @perfectra1n
## 📖 Documentation
* update doc references from triliumnext/notes to triliumnext/trilium by @perfectra1n
* Simple Update/Autoupdate Script by @serossi
* Improve OIDC docs by @JYC333
* Traditional Chinese README by @francistw
* README improvements by @meichthys
## 🌍 Internationalization
* Improvements to multiple languages:
* Chinese (Traditional)
* Spanish
* Some work started on new languages:
Portuguese (Brazil), Japanese, Russian, Serbian, Italian, Greek, Catalan
* Added new languages:
* Russian (translations by @questamor)
* Japanese language (translations by [acwr47](https://hosted.weblate.org/user/acwr47/))\[…\]
## 🛠️ Technical updates
* Add duplicateSubtree to backend API by @Geekswordsman
* Fixed CVE-2025-54798 in `tmp` dependency
* Remove unnecessary idea directory by @GrantZhu1001
* Support getting an attribute value by ID in BNote by @Geekswordsman
* only run nightly.yml on TriliumNext/Trilium by @maphew
* All the dialogs have been converted to React/Preact for better maintainability. **If you notice any regressions, please report them.**

View File

@@ -9141,10 +9141,24 @@
{ {
"type": "relation", "type": "relation",
"name": "internalLink", "name": "internalLink",
"value": "x59R8J8KV5Bp", "value": "bnyigUA2UK7s",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "9yEHzMyFirZR",
"isInheritable": false, "isInheritable": false,
"position": 20 "position": 20
}, },
{
"type": "relation",
"name": "internalLink",
"value": "x59R8J8KV5Bp",
"isInheritable": false,
"position": 30
},
{ {
"type": "label", "type": "label",
"name": "shareAlias", "name": "shareAlias",
@@ -9152,20 +9166,6 @@
"isInheritable": false, "isInheritable": false,
"position": 30 "position": 30
}, },
{
"type": "relation",
"name": "internalLink",
"value": "bnyigUA2UK7s",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "9yEHzMyFirZR",
"isInheritable": false,
"position": 50
},
{ {
"type": "label", "type": "label",
"name": "iconClass", "name": "iconClass",
@@ -9207,14 +9207,14 @@
{ {
"type": "relation", "type": "relation",
"name": "internalLink", "name": "internalLink",
"value": "tAassRL4RSQL", "value": "x3i7MxGccDuM",
"isInheritable": false, "isInheritable": false,
"position": 10 "position": 10
}, },
{ {
"type": "relation", "type": "relation",
"name": "internalLink", "name": "internalLink",
"value": "x3i7MxGccDuM", "value": "tAassRL4RSQL",
"isInheritable": false, "isInheritable": false,
"position": 20 "position": 20
}, },

View File

@@ -1,5 +1,5 @@
# Configuration (config.ini or environment variables) # Configuration (config.ini or environment variables)
Trilium supports configuration via a file named `config.ini` and environment variables. Please review the file named [config-sample.ini](https://github.com/TriliumNext/Trilium/blob/develop/apps/server/src/assets/config-sample.ini) in the [Notes](https://github.com/TriliumNext/Trilium) repository to see what values are supported. Trilium supports configuration via a file named `config.ini` and environment variables. Please review the file named [config-sample.ini](https://github.com/TriliumNext/Trilium/blob/main/apps/server/src/assets/config-sample.ini) in the [Trilium](https://github.com/TriliumNext/Trilium) repository to see what values are supported.
You can provide the same values via environment variables instead of the `config.ini` file, and these environment variables use the following format: You can provide the same values via environment variables instead of the `config.ini` file, and these environment variables use the following format:

View File

@@ -24,5 +24,11 @@ retentionDays=7
Or via the environment variable `TRILIUM_LOGGING_RETENTION_DAYS`. Or via the environment variable `TRILIUM_LOGGING_RETENTION_DAYS`.
Special cases:
* Positive values indicate the number of days worth of logs to keep
* A value of 0 results with the default value (90 days) to be used
* Negative values (e.g. `-1`) result with all logs to be kept, irrespective how ancient and numerous (and
> [!NOTE] > [!NOTE]
> If you set the retention days to a low number, you might notice that not all the log files are being deleted. This is because a minimum number of logs (7 at the time of writing) is maintained at all times. > If you set the retention days to a low number, you might notice that not all the log files are being deleted. This is because a minimum number of logs (7 at the time of writing) is maintained at all times.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@triliumnext/source", "name": "@triliumnext/source",
"version": "0.97.2", "version": "0.98.0",
"description": "Build your personal knowledge base with Trilium Notes", "description": "Build your personal knowledge base with Trilium Notes",
"directories": { "directories": {
"doc": "docs" "doc": "docs"
@@ -40,7 +40,7 @@
"@playwright/test": "^1.36.0", "@playwright/test": "^1.36.0",
"@triliumnext/server": "workspace:*", "@triliumnext/server": "workspace:*",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "22.17.1", "@types/node": "22.17.2",
"@vitest/coverage-v8": "^3.0.5", "@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0", "@vitest/ui": "^3.0.0",
"chalk": "5.5.0", "chalk": "5.5.0",
@@ -58,7 +58,7 @@
"react-refresh": "^0.17.0", "react-refresh": "^0.17.0",
"rollup-plugin-webpack-stats": "2.1.3", "rollup-plugin-webpack-stats": "2.1.3",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"tsx": "4.20.3", "tsx": "4.20.4",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"typescript-eslint": "^8.19.0", "typescript-eslint": "^8.19.0",
"upath": "2.0.1", "upath": "2.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@triliumnext/commons", "name": "@triliumnext/commons",
"version": "0.97.2", "version": "0.98.0",
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.", "description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -50,6 +50,11 @@ const UNSORTED_LOCALES: Locale[] = [
name: "Русский", name: "Русский",
electronLocale: "ru" electronLocale: "ru"
}, },
{
id: "ja",
name: "日本語",
electronLocale: "ja"
},
/* /*
* Right to left languages * Right to left languages

View File

@@ -57,7 +57,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"turndown": "7.2.0", "turndown": "7.2.1",
"turndown-attendant": "0.0.3" "turndown-attendant": "0.0.3"
} }
} }

576
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff