Compare commits

...

630 Commits

Author SHA1 Message Date
Elian Doran
7a1ec266ad chore(release): prepare for v0.97.2 2025-08-02 23:34:39 +03:00
Elian Doran
42fedaa241 chore(deps): update nx monorepo to v21.3.11 (#6523) 2025-08-02 10:38:29 +03:00
renovate[bot]
4387bd4c6f chore(deps): update nx monorepo to v21.3.11 2025-08-02 07:22:22 +00:00
Elian Doran
51e1367b82 chore(deps): update dependency typescript to v5.9.2 (#6525) 2025-08-02 10:17:05 +03:00
renovate[bot]
8bea3f4422 chore(deps): update dependency typescript to v5.9.2 2025-08-02 07:00:23 +00:00
Elian Doran
0eb2e405ff chore(deps): update node.js to v22.18.0 (#6527) 2025-08-02 09:57:18 +03:00
Elian Doran
5dbd4a765f fix(deps): update dependency @codemirror/lang-markdown to v6.3.4 (#6524) 2025-08-02 09:57:04 +03:00
Elian Doran
f6961c7e06 chore(deps): update dependency typedoc to v0.28.9 (#6522) 2025-08-02 09:56:38 +03:00
Elian Doran
8d3ba90072 chore(deps): update dependency electron to v37.2.5 (#6521) 2025-08-02 09:55:22 +03:00
Elian Doran
3772412d82 chore(deps): update dependency @playwright/test to v1.54.2 (#6520) 2025-08-02 09:55:05 +03:00
Elian Doran
84389f467e chore(deps): update pnpm to v10.14.0 (#6528) 2025-08-02 09:54:42 +03:00
Elian Doran
eb41e0f96f chore(deps): update svelte monorepo (#6529) 2025-08-02 09:53:44 +03:00
renovate[bot]
2d44dff997 chore(deps): update svelte monorepo 2025-08-02 02:29:36 +00:00
renovate[bot]
1483bf3d46 chore(deps): update pnpm to v10.14.0 2025-08-02 02:28:49 +00:00
renovate[bot]
064cf6a3ee chore(deps): update node.js to v22.18.0 2025-08-02 02:28:39 +00:00
renovate[bot]
0c0d5eaa0a fix(deps): update dependency @codemirror/lang-markdown to v6.3.4 2025-08-02 02:27:03 +00:00
renovate[bot]
afecb33b5c chore(deps): update dependency typedoc to v0.28.9 2025-08-02 02:25:23 +00:00
renovate[bot]
fbb1e3a302 chore(deps): update dependency electron to v37.2.5 2025-08-02 02:25:17 +00:00
renovate[bot]
8704350359 chore(deps): update dependency @playwright/test to v1.54.2 2025-08-02 02:24:27 +00:00
Elian Doran
d09e725d98 fix(note_list): copy to clipboard button also opening note 2025-08-01 13:07:58 +03:00
Elian Doran
8be5b149c4 fix(note_list): note tooltip showing up 2025-08-01 13:05:17 +03:00
Elian Doran
faeea6af18 Merge branch 'main' of github.com:TriliumNext/trilium 2025-08-01 00:23:13 +03:00
Elian Doran
3fa5ea1010 docs(readme): mention translations 2025-08-01 00:23:09 +03:00
Elian Doran
6aa31ae125 Translations update from Hosted Weblate (#6516) 2025-08-01 00:13:50 +03:00
wild
27f2e9c286 Translated using Weblate (Serbian)
Currently translated at 9.1% (142 of 1559 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/sr/
2025-07-31 21:05:37 +00:00
Aitanuqui
67cc36fdd2 Translated using Weblate (Spanish)
Currently translated at 89.4% (338 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/es/
2025-07-31 21:05:36 +00:00
Aitanuqui
ef7297e03b Translated using Weblate (Spanish)
Currently translated at 96.4% (1503 of 1559 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/es/
2025-07-31 21:05:35 +00:00
wild
97a5314cdb Added translation using Weblate (Serbian) 2025-07-31 21:05:34 +00:00
Elian Doran
a1195a2856 feat(search): support doc notes (closes #6515) 2025-08-01 00:05:17 +03:00
Elian Doran
81419c6fe3 Translations update from Hosted Weblate (#6514) 2025-07-31 11:52:22 +03:00
Elian Doran
b8da793353 Translated using Weblate (Romanian)
Currently translated at 99.7% (377 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ro/
2025-07-31 10:51:13 +02:00
Adorian Doran
8140fa79cc Translated using Weblate (Romanian)
Currently translated at 99.7% (377 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ro/
2025-07-31 10:51:12 +02:00
Elian Doran
abff4fe67d Translated using Weblate (Romanian)
Currently translated at 100.0% (1559 of 1559 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ro/
2025-07-31 10:51:12 +02:00
Elian Doran
ec8f737eba Translations update from Hosted Weblate (#6513) 2025-07-31 09:22:49 +03:00
Hosted Weblate
cc6688ea00 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/
2025-07-31 08:09:52 +02:00
Elian Doran
c448b29be7 chore(deps): update nx monorepo to v21.3.10 (#6511) 2025-07-31 08:04:27 +03:00
Elian Doran
61bde294b3 chore(deps): update dependency openai to v5.11.0 (#6512) 2025-07-31 08:03:35 +03:00
Elian Doran
acab81c61e chore(deps): update dependency eslint-plugin-playwright to v2.2.2 (#6510) 2025-07-31 08:03:20 +03:00
Elian Doran
1dd965973b chore(deps): update dependency @types/tabulator-tables to v6.2.9 (#6509) 2025-07-31 08:03:07 +03:00
renovate[bot]
d61981033f chore(deps): update dependency openai to v5.11.0 2025-07-31 01:17:46 +00:00
renovate[bot]
30197ba7ce chore(deps): update nx monorepo to v21.3.10 2025-07-31 01:17:03 +00:00
renovate[bot]
1b6c957334 chore(deps): update dependency eslint-plugin-playwright to v2.2.2 2025-07-31 01:16:19 +00:00
renovate[bot]
fb7a397bf9 chore(deps): update dependency @types/tabulator-tables to v6.2.9 2025-07-31 01:15:39 +00:00
Elian Doran
133c9c5a7b Remove unmaintained hotkeys dependency (#6507) 2025-07-31 00:02:29 +03:00
Elian Doran
8a587d4d21 chore(client): fix typecheck issues 2025-07-30 23:46:43 +03:00
Elian Doran
29b813fa3b Merge remote-tracking branch 'origin/main' into feature/replace_hotkeys_library 2025-07-30 23:29:16 +03:00
Elian Doran
1dfe27d3df feat(web_view): open externally from note preview 2025-07-30 23:18:05 +03:00
Elian Doran
cda8fc7146 style(next): improve border for pdf notes preview 2025-07-30 23:05:46 +03:00
Elian Doran
acb16f751b style(next): improve border for image notes preview 2025-07-30 23:03:02 +03:00
Elian Doran
a1ac276be5 feat(web_view): hide attribute from attribute preview 2025-07-30 22:58:42 +03:00
Elian Doran
54e3ab5139 fix(command_palette): full screen not working on the browser 2025-07-30 22:50:08 +03:00
Elian Doran
baf341b312 fix(command_palette): find in text not shown 2025-07-30 22:47:11 +03:00
Elian Doran
5b074c2e22 fix(command_palette): some note context-aware commands not working 2025-07-30 22:45:31 +03:00
Elian Doran
11d086ef12 fix(command_palette): text editor-based issues not working 2025-07-30 22:39:37 +03:00
Elian Doran
0e6b10e400 feat(command_palette): active tab-related commands on browser 2025-07-30 22:33:22 +03:00
Elian Doran
0240222998 chore(command_palette): disable two unsupported commands 2025-07-30 19:54:19 +03:00
Elian Doran
7fc739487f chore(command_palette): hide jump to note / command palette 2025-07-30 19:50:07 +03:00
Elian Doran
f6e275709f fix(command_palette): sort child notes not working 2025-07-30 19:47:01 +03:00
Elian Doran
7e01dfd220 fix(sort): refresh when sorting notes via dialog 2025-07-30 19:45:01 +03:00
Elian Doran
d5866a99ec test(hotkeys): add some basic tests 2025-07-30 19:30:27 +03:00
Elian Doran
5289d41b12 fix(hotkeys): shortcuts with number keys not working 2025-07-30 14:43:37 +03:00
Elian Doran
030178cad2 fix(hotkeys): errors on mouse clicks 2025-07-30 14:29:59 +03:00
Elian Doran
5d00630452 refactor(hotkeys): simplify normalization 2025-07-30 14:26:51 +03:00
Elian Doran
eb805bfa2a refactor(hotkeys): remove no longer necessary library 2025-07-30 14:19:02 +03:00
Elian Doran
ee3a8e105e refactor(hotkeys): remove unnecessary initialization code 2025-07-30 14:18:09 +03:00
Elian Doran
97fb273e7f fix(hotkeys): tree not using the right API 2025-07-30 14:15:29 +03:00
Elian Doran
2ef9009384 refactor(hotkeys): use own (rough) implementation 2025-07-30 14:11:41 +03:00
Elian Doran
27c7888628 fix(deps): update dependency @maplibre/maplibre-gl-leaflet to v0.1.3 (#6503) 2025-07-30 11:04:07 +03:00
Elian Doran
b4de37a9f4 chore(deps): update electron-forge monorepo to v7.8.2 (#6501) 2025-07-30 11:03:43 +03:00
Elian Doran
1c5ebb54f8 chore(deps): update nx monorepo to v21.3.9 (#6502) 2025-07-30 11:03:31 +03:00
Elian Doran
f3e69dd6bd Merge branch 'main' of github.com:TriliumNext/trilium 2025-07-30 11:03:17 +03:00
Elian Doran
66364f5ce0 test(server/e2e): add more assertions to try to avoid flaky test 2025-07-30 11:03:12 +03:00
renovate[bot]
f25a1fb865 chore(deps): update electron-forge monorepo to v7.8.2 2025-07-30 07:43:22 +00:00
renovate[bot]
62c5b8b1fc chore(deps): update nx monorepo to v21.3.9 2025-07-30 07:41:02 +00:00
Elian Doran
2b0de37fc0 chore(deps): update dependency @types/express-http-proxy to v1.6.7 (#6497) 2025-07-30 10:38:55 +03:00
Elian Doran
23ef73fe2f chore(deps): update dependency @types/node to v22.17.0 (#6504) 2025-07-30 10:38:23 +03:00
Elian Doran
92ac3ee4ef chore(deps): update dependency stylelint to v16.23.0 (#6505) 2025-07-30 10:36:12 +03:00
Elian Doran
a3ba5ca109 test(server/e2e): flaky test 2025-07-30 10:34:38 +03:00
renovate[bot]
5b4e81cf18 chore(deps): update dependency stylelint to v16.23.0 2025-07-30 06:53:17 +00:00
renovate[bot]
772e6f5ebc chore(deps): update dependency @types/node to v22.17.0 2025-07-30 06:52:25 +00:00
renovate[bot]
60a9428b8b fix(deps): update dependency @maplibre/maplibre-gl-leaflet to v0.1.3 2025-07-30 06:51:34 +00:00
renovate[bot]
a7752a8421 chore(deps): update dependency @types/express-http-proxy to v1.6.7 2025-07-30 06:48:57 +00:00
Elian Doran
aefa2315b7 fix(server/test): yet another cyclic import issue due to becca_loader 2025-07-30 09:19:02 +03:00
Elian Doran
37a79aeeab fix(server/test): non-platform agnostic test 2025-07-30 08:42:51 +03:00
Elian Doran
5bc4bdaeef fix(server/test): problematic cyclic dependency 2025-07-30 08:38:06 +03:00
Elian Doran
5e28df883d fix(server): migration failing due to geomap in protected mode (closes #6489) 2025-07-29 23:26:03 +03:00
Elian Doran
0a57748075 fix(deps): update dependency preact to v10.27.0 (#6500) 2025-07-29 08:49:15 +03:00
Elian Doran
45e3eee642 chore(deps): update dependency svelte to v5.37.1 (#6498) 2025-07-29 08:48:56 +03:00
Elian Doran
d724a80c2a chore(deps): update nx monorepo to v21.3.8 (#6499) 2025-07-29 08:48:31 +03:00
renovate[bot]
5ea8c94d18 fix(deps): update dependency preact to v10.27.0 2025-07-29 01:29:37 +00:00
renovate[bot]
769bc760b3 chore(deps): update nx monorepo to v21.3.8 2025-07-29 01:28:50 +00:00
renovate[bot]
f04f45ea62 chore(deps): update dependency svelte to v5.37.1 2025-07-29 01:28:09 +00:00
Elian Doran
a5cab6a2a2 Command palette (#6491) 2025-07-28 21:20:19 +03:00
Elian Doran
138611beaf chore(client): remove unnecessary log 2025-07-28 21:18:06 +03:00
Elian Doran
e1b608057a chore(deps): update dependency eslint-plugin-playwright to v2.2.1 (#6495) 2025-07-28 20:16:40 +03:00
Elian Doran
fed6d8329f chore(deps): update dependency typedoc to v0.28.8 (#6496) 2025-07-28 20:03:19 +03:00
Elian Doran
9d03d52f28 fix(hidden_subtree): unable to change language 2025-07-28 20:02:46 +03:00
Elian Doran
055e11174d refactor(hidden_subtree): deduplicate restoring title 2025-07-28 19:59:10 +03:00
Elian Doran
8fda2dd7f1 test(client): fix error due to JQuery 2025-07-28 18:58:26 +03:00
renovate[bot]
ea03695c75 chore(deps): update dependency typedoc to v0.28.8 2025-07-28 15:47:13 +00:00
renovate[bot]
17b206fc72 chore(deps): update dependency eslint-plugin-playwright to v2.2.1 2025-07-28 15:47:07 +00:00
Elian Doran
4ec8c5963a docs(guide): document command palette 2025-07-28 18:21:04 +03:00
Elian Doran
ab2d8accf5 chore(command_palette): hide system tray from web 2025-07-28 17:20:02 +03:00
Elian Doran
de8b7e9ebe feat(command_palette): sort commands by name 2025-07-28 17:17:11 +03:00
Elian Doran
18d11523a6 chore(server): add entry point for circular-deps 2025-07-28 15:19:15 +03:00
Elian Doran
7a0ab3c025 feat(command_palette): enforce title names 2025-07-28 15:19:05 +03:00
Elian Doran
3575a7dc93 fix(hidden_subtree): bring back enforcing branches for help 2025-07-28 13:15:12 +03:00
Elian Doran
bb9e7b1c6e fix(hidden_subtree): visible launchers broken due to branch enforcement 2025-07-28 12:20:14 +03:00
Elian Doran
115e9e0202 chore(test): undefined import when running under vitest 2025-07-28 12:16:31 +03:00
Elian Doran
e341de70c0 chore(command_palette): change placeholder 2025-07-28 11:21:18 +03:00
Elian Doran
1d1a0ac4fd fix(command_palette): print command showing modal 2025-07-28 11:15:48 +03:00
Elian Doran
d48470ffb1 Merge remote-tracking branch 'origin/main' into feature/command_palette 2025-07-28 11:12:47 +03:00
Elian Doran
6574ca42a3 chore(deps): update dependency svelte to v5.37.0 (#6492) 2025-07-28 10:52:11 +03:00
renovate[bot]
303ff35a76 chore(deps): update dependency svelte to v5.37.0 2025-07-28 00:38:54 +00:00
Elian Doran
e0850958b0 chore(client): type errors 2025-07-27 23:21:07 +03:00
Elian Doran
13115b9ed1 fix(keyboard_actions): missing keyboard action descriptions 2025-07-27 22:22:17 +03:00
Elian Doran
933a11e9db chore(command_palette): add translations 2025-07-27 22:16:04 +03:00
Elian Doran
6915993a35 feat(command_palette): remove duplicate actions 2025-07-27 22:12:08 +03:00
Elian Doran
237a4e9a74 feat(command_palette): hide electron-only actions on web 2025-07-27 22:05:24 +03:00
Elian Doran
1565a0fd80 feat(command_palette): differentiate tree-based operations 2025-07-27 21:47:30 +03:00
Elian Doran
e8b16287e0 refactor(command_palette): reduce duplication 2025-07-27 21:39:55 +03:00
Elian Doran
c09e124805 fix(command_palette): command title not updated while navigating 2025-07-27 21:36:42 +03:00
Elian Doran
b6f55b0e1a refactor(command_palette): unnecessary icon mapping 2025-07-27 21:18:00 +03:00
Elian Doran
964bc74b83 refactor(command_palette): use declarative command approach 2025-07-27 21:16:23 +03:00
Elian Doran
fa9b142cb7 fix(command_palette): triggering note tree actions 2025-07-27 21:03:31 +03:00
Elian Doran
7e3f412c84 fix(command_palette): missing icon 2025-07-27 20:41:01 +03:00
Elian Doran
82e16a5624 fix(command_palette): not showing after re-entering 2025-07-27 20:31:13 +03:00
Elian Doran
757488a95b feat(command_palette): improve dialog margins 2025-07-27 18:15:54 +03:00
Elian Doran
d7f154cfd1 feat(command_palette): improve layout 2025-07-27 18:11:43 +03:00
Elian Doran
3517715aab feat(command_palette): add icons to all actions 2025-07-27 17:41:00 +03:00
Elian Doran
d10bbdd7a7 feat(settings/keyboard_actions): display friendly name 2025-07-27 17:04:29 +03:00
Elian Doran
c4ec27bb1e chore(keyboard_actions): use translations for friendly names 2025-07-27 17:04:05 +03:00
Elian Doran
0b24553ace feat(keyboard_actions): add friendly names to all actions 2025-07-27 16:50:02 +03:00
Elian Doran
793867269b refactor(command_palette): separate model for keyboard shortcuts 2025-07-27 16:40:48 +03:00
Elian Doran
9508e92676 feat(command_palette): integrate all keyboard actions 2025-07-27 16:32:39 +03:00
Elian Doran
89378eae7b feat(command_palette): improve keyboard shortcut 2025-07-27 16:15:14 +03:00
Elian Doran
ace166a925 feat(command_palette): hide search in full text 2025-07-27 15:59:33 +03:00
Elian Doran
d59d544c0f style(command_palette): improve layout slightly 2025-07-27 15:49:12 +03:00
Elian Doran
37461d0eb3 refactor(command_palette): use CSS for styles 2025-07-27 15:44:47 +03:00
Elian Doran
126152ff63 feat(command_palette): display commands immediately 2025-07-27 15:42:44 +03:00
Elian Doran
60e19de0d1 feat(command_palette): add keyboard shortcut 2025-07-27 15:34:51 +03:00
Elian Doran
3247a9facc feat(command_palette): hide on command execution 2025-07-27 15:30:27 +03:00
Elian Doran
7b114bed26 feat(command_palette): basic implementation 2025-07-27 15:27:13 +03:00
Elian Doran
30ffbc760e Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-27 12:17:21 +03:00
Elian Doran
4420913049 fix(export/markdown): superscript and subscript not preserved (closes #4307) 2025-07-27 12:17:13 +03:00
Elian Doran
3762690c5f Merge branch 'main' of github.com:TriliumNext/trilium 2025-07-27 11:04:03 +03:00
Elian Doran
d684ac40d8 fix(forge): nightly failing due to minimatch 2025-07-27 10:55:27 +03:00
Elian Doran
d217379644 chore(deps): update dependency rollup-plugin-webpack-stats to v2.1.1 (#6487) 2025-07-27 09:14:18 +03:00
renovate[bot]
d5f7fa2fe5 chore(deps): update dependency rollup-plugin-webpack-stats to v2.1.1 2025-07-27 01:36:11 +00:00
Elian Doran
3e0ef10b25 fix(print): table captions not displayed properly (closes #6483) 2025-07-26 23:25:04 +03:00
Elian Doran
28f88f2407 chore(deps): update dependency dotenv to v17.2.1 (#6476) 2025-07-26 15:48:30 +03:00
Elian Doran
e525a7a0ff chore(deps): update dependency svelte to v5.36.17 (#6484) 2025-07-26 15:48:12 +03:00
Elian Doran
3415f38e0a fix(deps): update dependency eslint-linter-browserify to v9.32.0 (#6485) 2025-07-26 15:47:54 +03:00
Elian Doran
910c0faade chore(deps): update dependency cross-env to v10 (#6486) 2025-07-26 15:47:41 +03:00
renovate[bot]
4ad1bb5e3a chore(deps): update dependency cross-env to v10 2025-07-26 10:38:57 +00:00
renovate[bot]
97f6f0a945 fix(deps): update dependency eslint-linter-browserify to v9.32.0 2025-07-26 10:38:01 +00:00
renovate[bot]
bc78c17a11 chore(deps): update dependency svelte to v5.36.17 2025-07-26 10:37:05 +00:00
Elian Doran
b8e813f7bd feat(promoted_attributes): better indicate no value 2025-07-26 10:12:46 +03:00
Elian Doran
db3581eb26 feat(promoted_attributes): improve color picker aspect 2025-07-26 09:53:26 +03:00
Elian Doran
d23230df68 feat(promoted_attributes): support removing color 2025-07-26 09:49:32 +03:00
Elian Doran
b29781b614 feat(promoted_attributes): add color type 2025-07-26 09:29:27 +03:00
Elian Doran
7d7c3e7cdb fix(ws): new attachments' title not decrypted (closes #6473) 2025-07-25 23:21:44 +03:00
Elian Doran
cbd8cb80ab chore(deps): update nx monorepo to v21.3.7 (#6479) 2025-07-25 23:20:55 +03:00
renovate[bot]
bfcdc34faf chore(deps): update nx monorepo to v21.3.7 2025-07-25 20:20:42 +00:00
Elian Doran
c728e6047d chore(deps): update dependency jiti to v2.5.1 (#6478) 2025-07-25 23:18:45 +03:00
renovate[bot]
4c53a9ba8c chore(deps): update dependency jiti to v2.5.1 2025-07-25 19:47:38 +00:00
Elian Doran
e10a7da7e3 chore(deps): update dependency @sveltejs/kit to v2.26.1 (#6475) 2025-07-25 22:42:39 +03:00
Elian Doran
5cc431b1bf chore(deps): update dependency dotenv to v17.2.1 (#6477) 2025-07-25 22:42:22 +03:00
Elian Doran
734aa2fcb5 fix(deps): update dependency mind-elixir to v5.0.4 (#6480) 2025-07-25 22:42:06 +03:00
Elian Doran
5e37319d9b fix(deps): update eslint monorepo to v9.32.0 (#6481) 2025-07-25 22:41:56 +03:00
renovate[bot]
2e9eb6e3e9 fix(deps): update eslint monorepo to v9.32.0 2025-07-25 19:13:01 +00:00
renovate[bot]
9ce57b123a fix(deps): update dependency mind-elixir to v5.0.4 2025-07-25 19:11:23 +00:00
renovate[bot]
e793168afa chore(deps): update dependency dotenv to v17.2.1 2025-07-25 19:08:36 +00:00
renovate[bot]
d1513424e7 chore(deps): update dependency dotenv to v17.2.1 2025-07-25 19:07:41 +00:00
renovate[bot]
1436a01dbe chore(deps): update dependency @sveltejs/kit to v2.26.1 2025-07-25 19:06:44 +00:00
Elian Doran
b9b936b92a chore(client): change book type to collection (closes #6471) 2025-07-25 19:09:03 +03:00
Elian Doran
adf14bec31 fix(views/board): unable to scroll vertically 2025-07-25 19:00:12 +03:00
Elian Doran
ca1403ffea docs(guide): creating collections & adding a description 2025-07-25 18:06:40 +03:00
Elian Doran
06672e439e docs(guide): board view 2025-07-25 17:57:34 +03:00
Elian Doran
e851701a9e fix(views/board): unable to click while editing column 2025-07-25 16:29:25 +03:00
Elian Doran
9589164008 fix(views/board): column duplication after batch rename 2025-07-25 16:20:33 +03:00
Elian Doran
a88b067081 refactor(views/board): use in-memory model 2025-07-25 16:17:54 +03:00
Elian Doran
b3777e6900 fix(views/board): column desynchronising due to API management 2025-07-25 16:11:26 +03:00
Elian Doran
d2646e291d chore(views/board): remove unnecessary highlight 2025-07-25 15:16:17 +03:00
Elian Doran
99ab9ee66b chore(views/board): set up context menu on the header 2025-07-25 15:15:10 +03:00
Elian Doran
08678e74e6 refactor(views/board): unnecessary fields 2025-07-25 14:57:51 +03:00
Elian Doran
62de52ab17 refactor(views/board): unnecessary API to manually refresh the board 2025-07-25 14:56:50 +03:00
Elian Doran
d9820d9725 fix(views/board): column not clickable after dragging 2025-07-25 14:54:50 +03:00
Elian Doran
fe8a8eeac9 feat(views/board): react to icon and color changes 2025-07-25 14:42:05 +03:00
Elian Doran
dfeb414aff feat(views/board): reintroduce one-click title edit 2025-07-25 12:00:04 +03:00
Elian Doran
69f12a2916 feat(views/board): drag columns by the title and not by a handle 2025-07-25 11:56:44 +03:00
Elian Doran
2b062e938e chore(views/board): use translations 2025-07-25 11:46:18 +03:00
Elian Doran
e0299bd1ae style(views/board): improve new buttons 2025-07-25 11:42:44 +03:00
Elian Doran
ac2f1b56fe style(views/board): shorter cards and smaller gaps 2025-07-25 11:35:07 +03:00
Elian Doran
06d98f6fcf refactor(views/board): unnecessary imports 2025-07-25 11:31:57 +03:00
Elian Doran
bb660d15b2 style(next): improve excalidraw dropdown fit 2025-07-25 11:06:20 +03:00
Elian Doran
4d73cdefef style(client): fix checkboxes override causing issues for canvas (closes #6463) 2025-07-25 10:08:14 +03:00
Elian Doran
313ba3df80 chore(deps): update dependency vite to v7.0.6 (#6465) 2025-07-25 08:24:18 +03:00
renovate[bot]
15377c32c2 chore(deps): update dependency vite to v7.0.6 2025-07-24 20:42:01 +00:00
Elian Doran
22b52f7c4a Merge branch 'main' of github.com:TriliumNext/trilium 2025-07-24 23:39:48 +03:00
Elian Doran
7055f77c91 docs(guide): document new features for geomap 2025-07-24 23:39:25 +03:00
Elian Doran
051fe67176 feat(views/geo): react to icon changes 2025-07-24 22:46:36 +03:00
Elian Doran
90accfcc48 chore(client): fix type errors 2025-07-24 22:26:29 +03:00
Elian Doran
4f99db0c90 refactor(views/geo): use a different attribute 2025-07-24 22:18:30 +03:00
Elian Doran
aeb356bf54 feat(views/geo): allow displaying scale 2025-07-24 22:14:51 +03:00
Elian Doran
0dffa0f333 feat(book_properties): group dark map styles 2025-07-24 21:54:57 +03:00
Elian Doran
d17f5b8447 feat(book_properties): group map style into vector & raster 2025-07-24 21:49:55 +03:00
Elian Doran
b5a57b3c66 style(book_properties): align label properly 2025-07-24 21:32:02 +03:00
Elian Doran
987a3404a9 chore(deps): update ckeditor5 config packages to v12.1.0 (#6466) 2025-07-24 21:24:43 +03:00
Elian Doran
eddc30769f chore(deps): update svelte monorepo (#6467) 2025-07-24 21:23:49 +03:00
Elian Doran
4d455650ba refactor(views/board): split row/column handling 2025-07-24 21:18:49 +03:00
Elian Doran
e2157aab26 fix(views/board): reordering same column not working 2025-07-24 21:18:49 +03:00
Elian Doran
b277f4bf3f feat(views/board): basic refresh after column change 2025-07-24 21:18:48 +03:00
Elian Doran
4047452b0f feat(views/board): drag works in between columns 2025-07-24 21:18:48 +03:00
Elian Doran
cb37724879 feat(views/board): basic column drag support 2025-07-24 21:18:48 +03:00
renovate[bot]
8890893412 chore(deps): update svelte monorepo 2025-07-24 18:01:27 +00:00
renovate[bot]
d0cbda7c0d chore(deps): update ckeditor5 config packages to v12.1.0 2025-07-24 18:00:32 +00:00
Elian Doran
60e7b9ffb0 feat(views/geo): set default theme 2025-07-24 15:52:01 +03:00
Elian Doran
45457c6f76 feat(views/geo): invert marker label on dark themes 2025-07-24 15:46:11 +03:00
Elian Doran
737f41d92b refactor(views/geo): get rid of empty theme 2025-07-24 15:36:06 +03:00
Elian Doran
180841f364 refactor(views/geo): remove dependency to leaflet in map layer 2025-07-24 15:35:03 +03:00
Elian Doran
bea40d4c2f feat(views/geo): add the rest of the map layers 2025-07-24 15:33:39 +03:00
Elian Doran
5f9a054441 refactor(book_properties): use translations 2025-07-24 15:20:32 +03:00
Elian Doran
f90bf1ce7c feat(views/geo): add combobox to adjust style 2025-07-24 15:14:43 +03:00
Elian Doran
8c4ed2d4da feat(views/geo): support vector maps 2025-07-24 15:07:47 +03:00
Elian Doran
0e590a1bbf chore(views/geo): add versatiles vector styles 2025-07-24 15:07:34 +03:00
Elian Doran
218a096135 chore(nx): update instructions 2025-07-24 13:57:41 +03:00
Elian Doran
8407bce370 chore(package): add output style to server:start 2025-07-24 13:57:23 +03:00
Elian Doran
43229f0b99 chore(deps): update nx monorepo to v21.3.5 (#6455) 2025-07-24 10:53:08 +03:00
renovate[bot]
84fa0002b9 chore(deps): update nx monorepo to v21.3.5 2025-07-24 07:22:18 +00:00
Elian Doran
e79c705b20 chore(deps): update dependency webdriverio to v9.18.4 (#6459) 2025-07-24 10:20:10 +03:00
Elian Doran
894d7ce15d chore(deps): update dependency express-openid-connect to v2.19.2 (#6456) 2025-07-24 10:05:07 +03:00
renovate[bot]
5830880582 chore(deps): update dependency webdriverio to v9.18.4 2025-07-24 06:52:40 +00:00
renovate[bot]
caab0f70ff chore(deps): update dependency express-openid-connect to v2.19.2 2025-07-24 06:51:51 +00:00
Elian Doran
641966fcdd chore(deps): update dependency @ckeditor/ckeditor5-package-tools to v4.0.2 (#6449) 2025-07-24 09:48:34 +03:00
renovate[bot]
24c22e9bbf chore(deps): update dependency @ckeditor/ckeditor5-package-tools to v4.0.2 2025-07-24 06:24:28 +00:00
Elian Doran
795f597bda chore(deps): update dependency jiti to v2.5.0 (#6457) 2025-07-24 09:22:19 +03:00
renovate[bot]
2228663a7e chore(deps): update dependency jiti to v2.5.0 2025-07-24 06:10:37 +00:00
Elian Doran
0c97df357d chore(deps): update dependency eslint-config-prettier to v10.1.8 (#6453) 2025-07-24 09:03:52 +03:00
Elian Doran
19f63f1be0 chore(deps): update dependency esbuild to v0.25.8 (#6452) 2025-07-24 09:03:26 +03:00
Elian Doran
fc000caf73 chore(deps): update svelte monorepo (#6436) 2025-07-24 09:03:08 +03:00
renovate[bot]
78929e0293 chore(deps): update svelte monorepo 2025-07-24 05:49:14 +00:00
renovate[bot]
71e22da987 chore(deps): update dependency esbuild to v0.25.8 2025-07-24 05:45:25 +00:00
renovate[bot]
24e99d9654 chore(deps): update dependency eslint-config-prettier to v10.1.8 2025-07-24 05:39:35 +00:00
Elian Doran
98299da424 chore(deps): update dependency @types/tabulator-tables to v6.2.8 (#6450) 2025-07-24 08:39:04 +03:00
Elian Doran
7014af66b6 chore(deps): update dependency electron to v37.2.4 (#6451) 2025-07-24 08:38:36 +03:00
Elian Doran
659bd90027 chore(deps): update dependency vite to v7.0.5 (#6454) 2025-07-24 08:37:54 +03:00
Elian Doran
146b0c284b chore(deps): update dependency stylelint to v16.22.0 (#6458) 2025-07-24 08:36:59 +03:00
Elian Doran
4a0ac8807f chore(deps): update typescript-eslint monorepo to v8.38.0 (#6460) 2025-07-24 08:36:20 +03:00
renovate[bot]
d67734832e chore(deps): update typescript-eslint monorepo to v8.38.0 2025-07-24 02:16:52 +00:00
renovate[bot]
1673bf026a chore(deps): update dependency stylelint to v16.22.0 2025-07-24 02:14:32 +00:00
renovate[bot]
1f29b000a9 chore(deps): update dependency vite to v7.0.5 2025-07-24 02:10:42 +00:00
renovate[bot]
a6d024123e chore(deps): update dependency electron to v37.2.4 2025-07-24 02:08:10 +00:00
renovate[bot]
fb1a7239ce chore(deps): update dependency @types/tabulator-tables to v6.2.8 2025-07-24 02:07:19 +00:00
Elian Doran
4f71d508cb chore(deps): audit 2025-07-23 22:32:49 +03:00
Elian Doran
2072bd61d1 fix(mermaid): lag during editing (closes #6443) 2025-07-23 22:28:15 +03:00
Elian Doran
6021178b7d feat(hidden_subtree): enforce original title in help 2025-07-23 21:22:58 +03:00
Elian Doran
179b0be2bb chore(deps): update dependency axios to v1.11.0 [security] (#6446) 2025-07-23 21:19:12 +03:00
renovate[bot]
bf2b45dd4a chore(deps): update dependency axios to v1.11.0 [security] 2025-07-23 16:53:39 +00:00
Elian Doran
513561234c chore(deps): update nx monorepo to v21.3.2 (#6438) 2025-07-23 08:57:04 +03:00
renovate[bot]
33da990ae7 chore(deps): update nx monorepo to v21.3.2 2025-07-23 05:43:38 +00:00
Elian Doran
4003946e68 chore(deps): fix pnpm-lock 2025-07-23 08:40:44 +03:00
Elian Doran
21f8d40789 chore(deps): update dependency @stylistic/eslint-plugin to v5.2.2 (#6432) 2025-07-23 08:30:28 +03:00
Elian Doran
d6c698e1d6 chore(deps): update dependency cheerio to v1.1.2 (#6433) 2025-07-23 08:30:04 +03:00
Elian Doran
6c227852ae chore(deps): update dependency openai to v5.10.2 (#6434) 2025-07-23 08:29:50 +03:00
Elian Doran
29cb22c4fd chore(deps): update dependency supertest to v7.1.4 (#6435) 2025-07-23 08:29:32 +03:00
Elian Doran
d040bc9e2d chore(deps): update dependency webdriverio to v9.18.3 (#6437) 2025-07-23 08:29:07 +03:00
Elian Doran
abb92f23a6 fix(deps): update dependency mind-elixir to v5.0.3 (#6439) 2025-07-23 08:28:47 +03:00
Elian Doran
da5c86bb69 chore(deps): update dependency @anthropic-ai/sdk to v0.57.0 (#6440) 2025-07-23 08:28:33 +03:00
Elian Doran
a0d428b12c chore(deps): update dependency express-openid-connect to v2.19.2 (#6441) 2025-07-23 08:28:20 +03:00
Elian Doran
e22fe20e23 chore(deps): update typescript-eslint monorepo to v8.38.0 (#6442) 2025-07-23 08:27:59 +03:00
renovate[bot]
1e6659aff9 chore(deps): update typescript-eslint monorepo to v8.38.0 2025-07-23 02:37:13 +00:00
renovate[bot]
60b32d5b05 chore(deps): update dependency express-openid-connect to v2.19.2 2025-07-23 02:35:35 +00:00
renovate[bot]
e2ee9053a0 chore(deps): update dependency @anthropic-ai/sdk to v0.57.0 2025-07-23 02:34:45 +00:00
renovate[bot]
d2f0422ecc fix(deps): update dependency mind-elixir to v5.0.3 2025-07-23 02:33:49 +00:00
renovate[bot]
bfd97da626 chore(deps): update dependency webdriverio to v9.18.3 2025-07-23 02:32:01 +00:00
renovate[bot]
1fd163f0bb chore(deps): update dependency supertest to v7.1.4 2025-07-23 02:30:18 +00:00
renovate[bot]
d15ce575df chore(deps): update dependency openai to v5.10.2 2025-07-23 02:29:21 +00:00
renovate[bot]
9999ff5a89 chore(deps): update dependency cheerio to v1.1.2 2025-07-23 02:28:26 +00:00
renovate[bot]
4653941082 chore(deps): update dependency @stylistic/eslint-plugin to v5.2.2 2025-07-23 02:27:33 +00:00
Elian Doran
fa509661ab Add grid to canvas (#6429) 2025-07-22 23:53:22 +03:00
Elian Doran
d9a289bf18 refactor(views/board): unnecessary re-render 2025-07-22 23:40:12 +03:00
Papierkorb2292
98c76b713d Save gridModeEnabled in CanvasContent 2025-07-22 19:12:08 +02:00
Papierkorb2292
05ed917a56 Removed disabling grid mode in ExcalidrawTypeWidget 2025-07-22 19:12:08 +02:00
Elian Doran
b833806ec7 feat(share): render inline mermaid (closes #5438) 2025-07-22 20:05:29 +03:00
Elian Doran
7fdef3418a refactor(share): check note type 2025-07-22 19:54:01 +03:00
Elian Doran
49e14ec542 feat(hidden_subtree): remove unexpected branches 2025-07-22 19:19:46 +03:00
Elian Doran
efd9244684 fix(help): missing branches if it was relocated 2025-07-22 18:52:39 +03:00
Elian Doran
318f2d1f8c docs(guide): relocate note list documentation 2025-07-22 18:33:46 +03:00
Elian Doran
92fa1cf052 fix(quick_edit): read-only notes not editable (closes #6425) 2025-07-22 17:30:03 +03:00
Elian Doran
17c6eb1680 fix(export/markdown): simple tables rendered as HTML (closes #6366) 2025-07-22 09:09:50 +03:00
Elian Doran
7c6af568d8 fix(share): ck text on dark theme not visible (closes #6427) 2025-07-22 08:44:45 +03:00
Elian Doran
23c9c6826e chore(env): add some instructions 2025-07-21 19:41:29 +03:00
Elian Doran
b08fda5e10 Kanban board (#6402) 2025-07-21 18:45:41 +03:00
Elian Doran
5ec3a49377 Merge remote-tracking branch 'origin/main' into feature/kanban_board 2025-07-21 18:24:36 +03:00
Elian Doran
1c728ae432 Merge branch 'release/v0.97.1' 2025-07-21 17:52:57 +03:00
Elian Doran
fd25c735c1 chore(release): bump version 2025-07-21 17:52:08 +03:00
Elian Doran
7de33907c5 docs(release): add change log for v0.97.1 2025-07-21 17:51:13 +03:00
Elian Doran
ec021be16c feat(views/board): display even if no children 2025-07-21 15:02:44 +03:00
Elian Doran
8b6826ffa4 feat(views/board): react to changes in "groupBy" 2025-07-21 15:02:38 +03:00
Elian Doran
00cc1ffe74 feat(views/board): add into view type switcher 2025-07-21 15:02:34 +03:00
Elian Doran
2384fdbaad chore(views/board): fix type errors 2025-07-21 15:02:31 +03:00
Elian Doran
08a93d81d7 feat(views/board): allow changing group by attribute 2025-07-21 15:02:28 +03:00
Elian Doran
86911100df refactor(views/board): use single point for obtaining status attribute 2025-07-21 15:02:22 +03:00
Elian Doran
ff01656268 chore(vscode): set up NX LLM integration 2025-07-21 15:02:20 +03:00
Elian Doran
d0ea6d9e8d feat(views/board): use same note title editing mechanism for insert above/below 2025-07-21 15:02:15 +03:00
Elian Doran
96ca3d5e38 fix(views/board): creating new notes would render as HTML 2025-07-21 13:14:07 +03:00
Elian Doran
3a569499cb feat(views/board): edit the note title inline on new 2025-07-21 11:28:46 +03:00
Elian Doran
545b19f978 fix(views/board): drop indicator remaining stuck 2025-07-21 11:19:14 +03:00
Elian Doran
d98be19c9a feat(views/board): set up differential renderer 2025-07-21 11:13:41 +03:00
Elian Doran
4826898c55 refactor(views/board): move drag logic to separate file 2025-07-21 11:01:49 +03:00
Elian Doran
482b592f77 feat(views/board): add drag preview when using touch 2025-07-21 11:01:49 +03:00
Elian Doran
939ebfe47b chore(deps): update dependency cheerio to v1.1.1 (#6417) 2025-07-21 10:07:37 +03:00
Elian Doran
c6dee1339b chore(deps): update dependency svelte to v5.36.12 (#6418) 2025-07-21 10:07:27 +03:00
Elian Doran
23f8c3ad3c chore(deps): update nx monorepo to v21.3.1 (#6419) 2025-07-21 10:06:25 +03:00
renovate[bot]
81c1b88376 chore(deps): update nx monorepo to v21.3.1 2025-07-21 02:58:10 +00:00
renovate[bot]
c4a85db698 chore(deps): update dependency svelte to v5.36.12 2025-07-21 02:57:09 +00:00
renovate[bot]
e6eda45c04 chore(deps): update dependency cheerio to v1.1.1 2025-07-21 02:56:06 +00:00
Elian Doran
a3014434cf chore(release): update version number 2025-07-20 23:39:58 +03:00
Elian Doran
3ebab2c126 docs(release): add changelog 2025-07-20 21:30:17 +03:00
Elian Doran
954619bd36 fix(views/table): note ID column being editable 2025-07-20 21:21:01 +03:00
Elian Doran
eb76362de4 chore(views/board): improve header 2025-07-20 20:55:41 +03:00
Elian Doran
1cde14859b feat(views/board): touch support 2025-07-20 20:31:07 +03:00
Elian Doran
c752b98995 chore(views/board): smaller add new column 2025-07-20 20:22:41 +03:00
Elian Doran
1f792ca418 feat(views/board): add new column 2025-07-20 20:06:54 +03:00
Elian Doran
b22e08b1eb refactor(views/board): use bulk API for renaming columns 2025-07-20 19:59:21 +03:00
Elian Doran
2b5029cc38 chore(views/board): delete values when deleting column 2025-07-20 19:52:16 +03:00
Elian Doran
9e936cb57b feat(views/board): delete empty columns 2025-07-20 19:52:10 +03:00
Elian Doran
e8fd2c1b3c fix(views/board): old column not removed when changing it 2025-07-20 19:52:06 +03:00
Elian Doran
977fbf54ee refactor(views/board): delegate storage to API 2025-07-20 19:52:01 +03:00
Elian Doran
3e5c91415d feat(views/board): rename columns 2025-07-20 19:51:56 +03:00
Elian Doran
d60b855f74 chore(views/board): disable move to for the current column 2025-07-20 19:51:52 +03:00
Elian Doran
4146192b6d chore(views/board): add icon to menu item 2025-07-20 19:51:46 +03:00
Elian Doran
26ee0ff48f feat(views/board): insert above/below 2025-07-20 17:35:52 +03:00
Elian Doran
6995fbfd06 chore(deps): update dependency esbuild to v0.25.8 (#6404) 2025-07-20 16:07:54 +03:00
Elian Doran
1763d80d5f feat(views/board): add move to in context menu 2025-07-20 13:24:22 +03:00
Elian Doran
a594e5147c feat(views/board): set up open in context menu 2025-07-20 12:42:19 +03:00
Elian Doran
e51ea1a619 feat(views/board): add context menu with delete 2025-07-20 12:40:30 +03:00
Elian Doran
83b72eafa6 Merge branch 'main' into renovate/esbuild-0.x-lockfile 2025-07-20 11:31:50 +03:00
Elian Doran
757a6777be chore(deps): update dependency svelte to v5.36.10 (#6405) 2025-07-20 11:30:49 +03:00
Elian Doran
37c9260dca feat(views/board): keep empty columns 2025-07-20 10:50:26 +03:00
Elian Doran
e1a8f4f5db chore(views/board): hide promoted attributes of collection 2025-07-20 10:50:13 +03:00
Elian Doran
b7b0b39afc feat(views/board): add preset notes 2025-07-20 10:36:36 +03:00
Elian Doran
af797489e8 feat(views/board): set up template 2025-07-20 10:30:48 +03:00
renovate[bot]
d003e91b89 chore(deps): update dependency svelte to v5.36.10 2025-07-20 01:09:12 +00:00
renovate[bot]
4a35df745a chore(deps): update dependency esbuild to v0.25.8 2025-07-20 01:08:07 +00:00
Elian Doran
b1b756b179 feat(views/board): store new columns into config 2025-07-19 22:21:24 +03:00
Elian Doran
9e3372df72 feat(views/board): react to changes in note title 2025-07-19 21:50:57 +03:00
Elian Doran
657df7a728 feat(views/board): add new item 2025-07-19 21:45:48 +03:00
Elian Doran
944f0b694b feat(views/board): open in popup 2025-07-19 21:09:55 +03:00
Elian Doran
efd409da17 fix(views/board): some runtime errors 2025-07-19 21:07:29 +03:00
Elian Doran
08d60c554c feat(views/board): set up reordering for same column 2025-07-19 20:44:54 +03:00
Elian Doran
a428ea7beb refactor(views/board): store both branch and note 2025-07-19 20:34:54 +03:00
Elian Doran
f69878b082 refactor(views/board): use branches instead of notes 2025-07-19 20:30:05 +03:00
Elian Doran
c5ffc2882b feat(views/board): react to changes 2025-07-19 19:57:02 +03:00
Elian Doran
765691751a feat(views/board): bypass horizontal scroll if column needs scrolling 2025-07-19 19:53:48 +03:00
Elian Doran
f19e5977c2 feat(views/board): set up dragging 2025-07-19 19:48:03 +03:00
Elian Doran
8f8b9af862 feat(views/board): set up scroll via mouse wheel 2025-07-19 19:31:13 +03:00
Elian Doran
3e7dc71995 feat(views/board): make scrollable 2025-07-19 19:23:42 +03:00
Elian Doran
2a25cd8686 feat(views/board): fixed column size 2025-07-19 19:20:32 +03:00
Elian Doran
7664839135 feat(views/board): display note icon 2025-07-19 19:16:39 +03:00
Elian Doran
47daebc65a feat(views/board): improve display of the notes 2025-07-19 19:03:09 +03:00
Elian Doran
0d18b944b6 feat(views/board): display columns 2025-07-19 18:44:50 +03:00
Elian Doran
951b5384a3 chore(views/board): prepare to group by attribute 2025-07-19 18:39:24 +03:00
Elian Doran
11547ecaa3 chore(views/board): create empty board 2025-07-19 18:29:31 +03:00
Adorian Doran
713a0f5b09 Merge branch 'main' of https://github.com/TriliumNext/Trilium 2025-07-19 16:28:56 +03:00
Adorian Doran
2cf9c98b43 style/table view: tweak table footer 2025-07-19 16:28:52 +03:00
Elian Doran
d7af196a0c feat(views/table): hide multiplicity when adding a new column 2025-07-19 16:03:58 +03:00
Adorian Doran
c363be57b7 client/table view: tweak icons 2025-07-19 16:02:11 +03:00
Adorian Doran
10645790de Merge branch 'main' of https://github.com/TriliumNext/Trilium 2025-07-19 15:55:06 +03:00
Adorian Doran
8b18cf382c style(next)/dropdown menus: fix rotated icons 2025-07-19 15:54:56 +03:00
Elian Doran
7a131e0bcc feat(views/table): support color class for title 2025-07-19 15:44:43 +03:00
Elian Doran
3d264379cc fix(views/table): color no longer shown for reference links 2025-07-19 15:44:42 +03:00
Elian Doran
f405682ec1 Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-19 14:56:21 +03:00
Elian Doran
3debf3ce1c fix(views/calendar): not refreshing on note title change 2025-07-19 14:53:27 +03:00
Adorian Doran
5a76883969 Merge branch 'main' of https://github.com/TriliumNext/Trilium 2025-07-19 14:41:51 +03:00
Adorian Doran
6f51c5e0cc style/attribute detail dialog: fix stretched close button 2025-07-19 14:41:48 +03:00
Elian Doran
2c730d1f0b Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-19 14:24:12 +03:00
Elian Doran
d487da0b2f feat(views/table): update new column in context menu to support relations also 2025-07-19 14:23:42 +03:00
Elian Doran
cb8a5cbb62 chore(views/table): add icons to add new column/row context menu 2025-07-19 14:06:00 +03:00
Elian Doran
ceb08593d8 chore(views/table): use translations for new label/relation 2025-07-19 14:04:25 +03:00
Elian Doran
9dd0eb7b9b fix(views/table): not reacting to external attribute changes 2025-07-19 14:02:19 +03:00
Elian Doran
ebff644d24 fix(views/table): changing column inheritability not working 2025-07-19 13:31:46 +03:00
Elian Doran
beb1c15fa5 fix(views/table): inheritable checkbox not respected 2025-07-19 13:25:54 +03:00
Elian Doran
40a5eee211 docs(views/table): describe exactly how to remove relation 2025-07-19 13:00:08 +03:00
Elian Doran
8f393d0bae refactor(bulk_action): fix type error 2025-07-19 12:57:58 +03:00
Elian Doran
94dad49e2f refactor(bulk_action): full type safety for client 2025-07-19 12:56:37 +03:00
Elian Doran
409638151c refactor(bulk_action): add basic type safety for client 2025-07-19 12:54:16 +03:00
Elian Doran
0d3de92890 refactor(views/table): move bulk action implementation in service 2025-07-19 12:46:38 +03:00
Elian Doran
5d619131ec fix(views/table): bulk actions sometimes not working 2025-07-19 12:44:55 +03:00
Elian Doran
e2c8443778 refactor(bulk_action): remake types & change method signature 2025-07-19 12:32:47 +03:00
Elian Doran
daa4743967 refactor(server): add some type safety to bulk actions 2025-07-19 12:15:33 +03:00
Elian Doran
56553078ef docs(views/table): update documentation 2025-07-19 09:47:10 +03:00
Elian Doran
5584a06cb3 chore(deps): update nx monorepo to v21.3.0 (#6398) 2025-07-19 09:28:55 +03:00
renovate[bot]
cfeb69ace6 chore(deps): update nx monorepo to v21.3.0 2025-07-19 06:03:54 +00:00
Elian Doran
b0c8f110de chore(deps): update dependency @types/node to v22.16.5 (#6392) 2025-07-19 09:00:33 +03:00
Elian Doran
aba1266c45 chore(deps): update svelte monorepo (#6395) 2025-07-19 08:59:56 +03:00
Elian Doran
c331e0103d chore(deps): update dependency eslint-config-prettier to v10.1.8 (#6394) 2025-07-19 08:56:51 +03:00
Elian Doran
13978574e0 chore(deps): update dependency stylelint to v16.22.0 (#6397) 2025-07-19 08:56:25 +03:00
renovate[bot]
be85963558 chore(deps): update dependency stylelint to v16.22.0 2025-07-19 05:55:03 +00:00
Elian Doran
8c19261ced fix(deps): update dependency marked to v16.1.1 (#6396) 2025-07-19 08:53:07 +03:00
Elian Doran
7ca17fa609 chore(deps): update dependency esbuild to v0.25.7 (#6393) 2025-07-19 08:52:36 +03:00
renovate[bot]
3d107572df fix(deps): update dependency marked to v16.1.1 2025-07-19 01:52:34 +00:00
renovate[bot]
f7488655a7 chore(deps): update svelte monorepo 2025-07-19 01:51:41 +00:00
renovate[bot]
876e0a29d4 chore(deps): update dependency eslint-config-prettier to v10.1.8 2025-07-19 01:50:50 +00:00
renovate[bot]
af74375695 chore(deps): update dependency esbuild to v0.25.7 2025-07-19 01:50:00 +00:00
renovate[bot]
896965fec5 chore(deps): update dependency @types/node to v22.16.5 2025-07-19 01:49:07 +00:00
Elian Doran
ba5ef93c1a fix(views/table): wrong type when renaming relations 2025-07-18 21:07:29 +03:00
Elian Doran
ef1153d336 fix(views/table): insert direction no longer working 2025-07-18 20:37:16 +03:00
Elian Doran
0d347f8823 feat(views/table): allow creating relations 2025-07-18 16:52:13 +03:00
Elian Doran
897cdc26ae chore(deps): update dependency express-session to v1.18.2 (#6372) 2025-07-18 11:53:17 +03:00
Elian Doran
aba621c099 fix(deps): update dependency mermaid to v11.9.0 (#6384) 2025-07-18 11:53:03 +03:00
renovate[bot]
839813ebde fix(deps): update dependency mermaid to v11.9.0 2025-07-18 08:35:57 +00:00
renovate[bot]
545e2ddbfc chore(deps): update dependency express-session to v1.18.2 2025-07-18 08:35:17 +00:00
Elian Doran
1d63a5903a fix(deps): update dependency marked to v16.1.0 (#6383) 2025-07-18 11:31:35 +03:00
Elian Doran
2b34c00a0c chore(deps): update dependency openai to v5.10.1 (#6378) 2025-07-18 11:31:17 +03:00
Elian Doran
123068062a chore(deps): update dependency @stylistic/eslint-plugin to v5.2.0 (#6376) 2025-07-18 11:31:01 +03:00
Elian Doran
9a668e8709 chore(deps): update node.js to v22.17.1 (#6374) 2025-07-18 11:30:51 +03:00
Elian Doran
f6f8937d64 chore(deps): update dependency express-rate-limit to v8.0.1 (#6371) 2025-07-18 11:30:24 +03:00
Elian Doran
c9f53a2880 chore(deps): update dependency compression to v1.8.1 (#6369) 2025-07-18 11:30:17 +03:00
Elian Doran
2887e712c3 chore(deps): update dependency multer to v2.0.2 [security] (#6368) 2025-07-18 11:29:21 +03:00
renovate[bot]
5d3a0ed1b4 fix(deps): update dependency marked to v16.1.0 2025-07-18 08:06:03 +00:00
renovate[bot]
334b6319de chore(deps): update dependency openai to v5.10.1 2025-07-18 08:05:15 +00:00
renovate[bot]
4c118c0fd4 chore(deps): update dependency @stylistic/eslint-plugin to v5.2.0 2025-07-18 08:04:29 +00:00
renovate[bot]
db00d60684 chore(deps): update node.js to v22.17.1 2025-07-18 08:04:25 +00:00
renovate[bot]
25b74af363 chore(deps): update dependency express-rate-limit to v8.0.1 2025-07-18 08:03:31 +00:00
renovate[bot]
eb57cf97ad chore(deps): update dependency compression to v1.8.1 2025-07-18 08:02:36 +00:00
renovate[bot]
c92e24363f chore(deps): update dependency multer to v2.0.2 [security] 2025-07-18 08:01:44 +00:00
Elian Doran
8d5d00ac0f chore(deps): update nx monorepo to v21.2.4 (#6375) 2025-07-18 10:59:33 +03:00
renovate[bot]
8b457384ba chore(deps): update nx monorepo to v21.2.4 2025-07-18 07:27:59 +00:00
Elian Doran
fab2d53ece chore(deps): update dependency vite to v7.0.5 (#6373) 2025-07-18 10:25:50 +03:00
renovate[bot]
774f27d8d2 chore(deps): update dependency vite to v7.0.5 2025-07-18 07:10:21 +00:00
Elian Doran
d7f02ef1b3 chore(deps): update dependency webdriverio to v9.18.1 (#6380) 2025-07-18 10:07:52 +03:00
renovate[bot]
97eaa6294c chore(deps): update dependency webdriverio to v9.18.1 2025-07-18 06:44:57 +00:00
Elian Doran
dc02bb0850 chore(deps): update svelte monorepo (#6382) 2025-07-18 09:41:49 +03:00
renovate[bot]
2c8c041e1c chore(deps): update svelte monorepo 2025-07-18 06:04:00 +00:00
Elian Doran
874b1c6654 chore(deps): update dependency electron to v37.2.3 (#6370) 2025-07-18 09:02:15 +03:00
Elian Doran
fb982c7097 fix(views/table): regression in restoring column width 2025-07-18 09:01:24 +03:00
Elian Doran
b7f5ce600e chore(renovate): add more packages to svelte monorepo 2025-07-18 08:58:35 +03:00
renovate[bot]
91604c9e26 chore(deps): update dependency electron to v37.2.3 2025-07-18 01:55:54 +00:00
Elian Doran
c874333a37 chore(client): fix type errors 2025-07-17 22:38:00 +03:00
Elian Doran
1298b968f2 fix(views/table): relation display sometimes not showing up 2025-07-17 22:31:54 +03:00
Elian Doran
6fe5a854a7 feat(views/table): allow deleting relations 2025-07-17 21:44:09 +03:00
Elian Doran
aba3b5cb19 feat(views/table): hide all buttons in relation editor 2025-07-17 21:07:44 +03:00
Elian Doran
282aed22b5 feat(views/geomap): support recursive notes 2025-07-17 20:51:15 +03:00
Elian Doran
669a3d9dcf feat(views/table): automatic index col width 2025-07-17 20:44:31 +03:00
Elian Doran
9d7455d28a fix(views/table): expander style affecting row number 2025-07-17 20:44:00 +03:00
Elian Doran
4f0c8b081c feat(views): improve style in collections properties 2025-07-17 19:56:14 +03:00
Elian Doran
a5db5298a0 feat(views/table): integrate depth limit into collection properties 2025-07-17 19:44:34 +03:00
Elian Doran
876c6e9252 feat(views/table): allow limiting depth 2025-07-17 19:34:29 +03:00
Elian Doran
aef824d262 feat(views/table): add a context menu for the header outside columns 2025-07-17 15:36:33 +03:00
Elian Doran
a25ce42490 feat(views/table): allow hiding number row & title 2025-07-17 15:00:19 +03:00
Elian Doran
8b0fdaccf4 feat(views/table): improve alignment for first level + increase indentation 2025-07-17 14:45:38 +03:00
Elian Doran
bd840a2421 feat(views/table): align items expanders 2025-07-17 14:40:44 +03:00
Elian Doran
27d515f289 refactor(views/table): use builtin way of disabling branch elements 2025-07-17 14:34:40 +03:00
Elian Doran
df3b9faf8d fix(client): tree operations no longer working due to loss of focus 2025-07-17 14:05:19 +03:00
Elian Doran
0f129734ae fix(link): popup triggering with bare right click 2025-07-17 11:19:29 +03:00
Elian Doran
275aacfba9 chore(vscode): add search excludes 2025-07-16 21:40:14 +03:00
Elian Doran
e7f47a0663 feat(views/table): delete column definition as well 2025-07-16 21:40:01 +03:00
Elian Doran
66486541fe feat(client): batch delete column values 2025-07-16 21:30:16 +03:00
Elian Doran
34f1a84769 fix(views/table): wrong position when renaming column 2025-07-16 09:23:06 +03:00
Elian Doran
2244f0368f fix(views/table): index column ends up in the wrong position 2025-07-16 09:16:47 +03:00
Elian Doran
9d85005255 chore(deps): update dependency express-rate-limit to v8 (#6362) 2025-07-16 08:32:51 +03:00
Elian Doran
ad8629dca6 chore(deps): update typescript-eslint monorepo to v8.37.0 (#6361) 2025-07-16 08:31:56 +03:00
Elian Doran
cccfe0e05a chore(deps): update svelte monorepo (#6360) 2025-07-16 08:31:36 +03:00
Elian Doran
a8874257e8 fix(deps): update dependency @codemirror/view to v6.38.1 (#6359) 2025-07-16 08:29:40 +03:00
Elian Doran
f689c55f56 chore(deps): update node.js to v22.17.1 (#6358) 2025-07-16 08:28:48 +03:00
Elian Doran
853c7be8b8 chore(deps): update dependency openai to v5.9.2 (#6357) 2025-07-16 08:28:39 +03:00
Elian Doran
823df1e12d chore(deps): update dependency electron to v37.2.2 (#6356) 2025-07-16 08:14:56 +03:00
renovate[bot]
7570f818e9 chore(deps): update dependency express-rate-limit to v8 2025-07-16 02:39:01 +00:00
renovate[bot]
03aa5aea2c chore(deps): update typescript-eslint monorepo to v8.37.0 2025-07-16 02:38:09 +00:00
renovate[bot]
a4e86ac353 chore(deps): update svelte monorepo 2025-07-16 02:36:37 +00:00
renovate[bot]
cf6efc050a fix(deps): update dependency @codemirror/view to v6.38.1 2025-07-16 02:35:51 +00:00
renovate[bot]
3e0802176b chore(deps): update node.js to v22.17.1 2025-07-16 02:35:04 +00:00
renovate[bot]
697954d4d9 chore(deps): update dependency openai to v5.9.2 2025-07-16 02:34:06 +00:00
renovate[bot]
741f6c1114 chore(deps): update dependency electron to v37.2.2 2025-07-16 02:33:19 +00:00
Adorian Doran
b2237ffa51 style/collections/tables: tweak nested rows 2025-07-16 05:23:13 +03:00
Adorian Doran
7b6d11bffa style/collections/tables: fix frozen cells overlapping with the outline of the left-side cells 2025-07-16 05:04:06 +03:00
Adorian Doran
97565e8f36 style(next)/collection/tables: improve the color scheme 2025-07-16 04:22:43 +03:00
perf3ct
c0dfee8439 fix(metrics): don't assign a timestamp to Prometheus metrics, let the scraper assign the timestamp to the time series 2025-07-15 20:39:36 +00:00
Elian Doran
fc98240614 chore(client): fix type error 2025-07-15 22:36:30 +03:00
Elian Doran
169d1203c2 fix(views/table): some context menu items active when they shouldn't 2025-07-15 22:30:52 +03:00
Elian Doran
f3350bc8f5 refactor(views/table): better cleanup 2025-07-15 22:06:32 +03:00
Elian Doran
504a19275c feat(views/table): basic renaming of fields 2025-07-15 21:48:16 +03:00
Elian Doran
14cdc52670 feat(views/table): support renaming columns 2025-07-15 20:42:47 +03:00
Elian Doran
cf8063f311 feat(views/table): format note ID as monospace 2025-07-15 19:32:13 +03:00
Elian Doran
aa8902f5b9 fix(client): popup not displayed for existing attributes (closes #5718) 2025-07-15 18:55:29 +03:00
Elian Doran
7cd0e664ac feat(views/table): basic editing of columns (rename not supported) 2025-07-15 18:51:51 +03:00
Elian Doran
a04804d3fa fix(views/table): wrong specs when restoring columns 2025-07-15 18:39:20 +03:00
Elian Doran
86f90e6685 fix(api): also rate limit etapi docs endpoint (#6352) 2025-07-15 17:06:52 +03:00
Elian Doran
8131a4b3d2 fix(views/table): events/commands not well sent 2025-07-15 15:49:32 +03:00
Elian Doran
b91a3e13b0 refactor(views/table): move row editing to own component 2025-07-15 15:32:30 +03:00
Elian Doran
5a7a0d32d1 refactor(views/table): move col editing to own component 2025-07-15 14:53:18 +03:00
perf3ct
3f5df18d6c fix(api): also rate limit etapi docs endpoint 2025-07-14 21:12:00 +00:00
Elian Doran
df2cede075 fix(views/calendar): nested entries in calendar view 2025-07-14 23:12:55 +03:00
Elian Doran
4321c161ac fix(views/calendar): duplicate entries in calendar view 2025-07-14 23:07:26 +03:00
Elian Doran
b1f0c64ef2 chore(views/geo): typing issue 2025-07-14 22:52:37 +03:00
Elian Doran
c9b37dcc77 refactor(views/table): rename event 2025-07-14 21:06:44 +03:00
Elian Doran
ab093ed9a0 chore(views/table): add translations 2025-07-14 20:59:29 +03:00
Elian Doran
cf31367acd feat(views/table): insert column to the right 2025-07-14 20:42:37 +03:00
Elian Doran
e3d306cac3 fix(views/table): wrong insert position for insert left 2025-07-14 20:34:05 +03:00
Elian Doran
960d321019 fix(views/table): position not restored after new columns (closes #6285) 2025-07-14 20:32:24 +03:00
Elian Doran
2d4ac93221 feat(views/table): basic implementation for inserting columns at position 2025-07-14 19:14:10 +03:00
Elian Doran
d4a4f15416 refactor(views/table): move attribute detail widget to view 2025-07-14 17:29:20 +03:00
Elian Doran
504a842d37 feat(views/table): force a refresh if #sorted is changed 2025-07-14 17:02:07 +03:00
Elian Doran
ded5b1f5d2 feat(views/table): expand child notes by default 2025-07-14 17:00:01 +03:00
Elian Doran
fcbbc21a80 feat(views/table): force a refresh if data tree changes 2025-07-14 16:58:14 +03:00
Elian Doran
38fce25b86 fix(views/table): show/hide columns not always updated properly 2025-07-14 16:51:20 +03:00
Elian Doran
4cc2fa5300 fix(snippets): warning about missing note IDs when deleting 2025-07-14 16:49:42 +03:00
Elian Doran
4a82c3f65a fix(views/table): insert above/below not working in nested trees 2025-07-14 16:49:29 +03:00
Elian Doran
b255d70e18 fix(views/table): context menu remains active while clicking on an expand/collapse button 2025-07-14 16:24:54 +03:00
Elian Doran
caa842cd55 fix(views/table): unable to update state for newly created rows 2025-07-14 16:16:55 +03:00
Elian Doran
cd338085fb refactor(views/table): clean up 2025-07-14 15:52:21 +03:00
Elian Doran
e703ce92a8 refactor(views/table): simplify context menu handling 2025-07-14 15:46:22 +03:00
Elian Doran
84479a2c2a feat(views/table): focus if creating child note 2025-07-14 15:38:57 +03:00
Elian Doran
c13969217c feat(views/table): insert child note 2025-07-14 13:37:18 +03:00
Elian Doran
402540f483 feat(views/table): support recursive children update 2025-07-14 13:15:41 +03:00
Elian Doran
8c56315313 refactor(views): move full height detection to rendererer 2025-07-14 12:56:17 +03:00
Elian Doran
b29c3eff6e refactor(views): prepare for supporting subtrees 2025-07-14 12:53:11 +03:00
Elian Doran
ec7dacfc9b feat(views/table): improve expand/collapse button 2025-07-14 12:04:13 +03:00
Elian Doran
5f9a6a9f76 feat(views/table): integrate expander into note title section 2025-07-14 11:39:12 +03:00
Elian Doran
28f4aea3d5 refactor(views/table): use slightly more performant formatter for row number 2025-07-14 11:30:46 +03:00
Elian Doran
8d29c5fe1b feat(views/table): hide draggable rows if not supported 2025-07-14 11:29:14 +03:00
Elian Doran
ccd935b562 refactor(views/table): don't configure reordering rows if not available 2025-07-14 11:22:32 +03:00
Elian Doran
d77a49857b feat(views/table): basic nested tree support 2025-07-14 11:11:08 +03:00
Elian Doran
e30478e5d4 chore(views/table): disable menu module since it's no longer necessary 2025-07-14 10:45:01 +03:00
Elian Doran
71863752cd feat(views/table): display both promoted and non-promoted attributes 2025-07-14 10:38:45 +03:00
Elian Doran
e4a2a8e56d fix(text): selection and cursor not maintained properly when switching tabs 2025-07-14 09:58:58 +03:00
Elian Doran
0f1c505823 fix(tab): editor not focused after switching tabs 2025-07-14 09:58:58 +03:00
Elian Doran
1ecce11113 chore(deps): update dependency vite-plugin-static-copy to v3.1.1 (#6345) 2025-07-14 08:10:18 +03:00
renovate[bot]
2287d67fb5 chore(deps): update dependency vite-plugin-static-copy to v3.1.1 2025-07-13 19:16:04 +00:00
Elian Doran
5b4f17ef3d Update README.md (#6344) 2025-07-13 22:14:33 +03:00
Elian Doran
3720ab6df6 fix(views/table): not reacting to title changes 2025-07-13 21:38:23 +03:00
diyoyo
3c893d69e5 Update README.md
updating from `Notes` to `Trilium` in the `Contribute`section.
2025-07-13 20:29:02 +02:00
Elian Doran
b93a4a3e42 fix(views/table): booleans not working 2025-07-13 21:06:41 +03:00
Elian Doran
23cef0ab94 chore(views/table): translate row menu 2025-07-13 16:56:03 +03:00
Elian Doran
c8ffb8d694 chore(views/table): translate column menu 2025-07-13 16:52:29 +03:00
Elian Doran
08e08d8920 feat(views/table): improve column context menu 2025-07-13 16:45:04 +03:00
Elian Doran
7acd300163 feat(views/table): add option to clear sorting 2025-07-13 16:41:43 +03:00
Elian Doran
d8d95db4ec feat(views/table): add sort by 2025-07-13 16:37:45 +03:00
Elian Doran
af97d3ef1d feat(views/table): add back show/hide columns 2025-07-13 16:22:57 +03:00
Elian Doran
c65ec14943 feat(views/table): hide column in contetx menu 2025-07-13 14:37:13 +03:00
Elian Doran
adfdc7edb4 feat(views/table): drag handle to avoid editing issues 2025-07-13 14:24:12 +03:00
Elian Doran
8cced607eb feat(views/table): insert row before 2025-07-13 14:10:37 +03:00
Elian Doran
5dd5af90c2 feat(views/table): insert row below 2025-07-13 13:06:53 +03:00
Elian Doran
7a48333b4f chore(deps): update dependency @sveltejs/vite-plugin-svelte to v6 (#6341) 2025-07-13 08:22:24 +03:00
Elian Doran
7044533398 fix(deps): update dependency mind-elixir to v5.0.2 (#6340) 2025-07-13 08:21:06 +03:00
renovate[bot]
560aad8df6 chore(deps): update dependency @sveltejs/vite-plugin-svelte to v6 2025-07-13 01:34:38 +00:00
renovate[bot]
36c2099b2e fix(deps): update dependency mind-elixir to v5.0.2 2025-07-13 01:33:47 +00:00
Elian Doran
6c157675d7 feat(views/table): open in new tab/quick edit 2025-07-13 00:44:44 +03:00
Elian Doran
458d66cb21 feat(views/table): delete row from context menu (closes #6288) 2025-07-13 00:36:34 +03:00
Elian Doran
201e8911c5 chore: prefer short name 2025-07-12 23:48:42 +03:00
Elian Doran
1b1ed2408f feat(funding): add Buy Me a Coffee 2025-07-12 23:28:07 +03:00
Elian Doran
62487d21d8 feat(funding): add LiberaPay 2025-07-12 23:20:55 +03:00
Elian Doran
bc752bdb0b fix(popup_editor): note icon overlapping with classic editor 2025-07-12 22:38:20 +03:00
Elian Doran
9e00d421fb fix(ckeditor): color and font mismatch after update 2025-07-12 22:34:27 +03:00
Elian Doran
e7f02fe22b fix(deps): update ckeditor monorepo (major) (#6283) 2025-07-12 22:03:15 +03:00
Elian Doran
6d694f8e53 chore(client): update types 2025-07-12 20:20:41 +03:00
Elian Doran
977befd0a7 chore(ckeditor5): update ckeditor theme variable names 2025-07-12 20:00:01 +03:00
Elian Doran
1566ae4fbd chore(ckeditor5): fix references: DocumentSelection -> ModelDocumentSelection 2025-07-12 19:45:00 +03:00
Elian Doran
4e97490cc6 chore(ckeditor5): fix references: Selectable -> ModelSelectable 2025-07-12 19:44:38 +03:00
Elian Doran
446d5a0fcc chore(ckeditor5): fix references: Item -> ModelItem 2025-07-12 19:44:12 +03:00
Elian Doran
1fd6465012 chore(ckeditor5): fix references: NodeAttributes -> ModelNodeAttributes 2025-07-12 19:43:48 +03:00
Elian Doran
6cea8e3b87 chore(ckeditor5): fix references: Range -> ModelRange 2025-07-12 19:43:18 +03:00
Elian Doran
28a63e0326 chore(ckeditor5): fix references: DocumentFragment -> ModelDocumentFragment 2025-07-12 19:42:54 +03:00
Elian Doran
b73da46111 chore(ckeditor5): fix references: Writer -> ModelWriter 2025-07-12 19:42:26 +03:00
Elian Doran
abafa8c2d2 chore(ckeditor5): fix references: Position -> ModelPosition 2025-07-12 19:41:30 +03:00
Elian Doran
4ae3272cdf chore(ckeditor5): fix references: Element -> ModelElement 2025-07-12 19:40:24 +03:00
Elian Doran
6aa3b8dbd7 chore(ckeditor5-admonition): fix references 2025-07-12 19:38:36 +03:00
Elian Doran
395e9b2228 chore(ckeditor5-admonition): fix references: DocumentFragment -> ViewDocumentFragment 2025-07-12 19:29:51 +03:00
Elian Doran
be33f68c52 chore(ckeditor5-math): fix references: DowncastWriter -> ViewDowncastWriter 2025-07-12 19:28:28 +03:00
Elian Doran
29d96381fa chore(ckeditor5-math): fix references: LiveRange -> ModelLiveRange 2025-07-12 19:27:46 +03:00
Elian Doran
da8eecf774 chore(ckeditor5-math): fix references: LivePosition -> ModelLivePosition 2025-07-12 19:27:22 +03:00
Elian Doran
de91326c12 chore(ckeditor5-math): fix references: Element -> ModelElement 2025-07-12 19:26:52 +03:00
Elian Doran
ee1c3c35d7 chore(ckeditor5-mermaid): fix references: {Item,Node} -> Model{Item,Node} 2025-07-12 19:25:40 +03:00
Elian Doran
70eece1429 chore(ckeditor5-mermaid): fix references: Element -> ModelElement 2025-07-12 19:24:39 +03:00
Elian Doran
b4f2be332b chore(ckeditor5-footnotes): fix references: Schema -> ModelSchema 2025-07-12 19:23:44 +03:00
Elian Doran
23fe76989b chore(ckeditor5-footnotes): fix references: Writer -> ModelWriter 2025-07-12 19:23:40 +03:00
Elian Doran
275d07659d chore(ckeditor5-footnotes): fix references: Range -> ModelRange 2025-07-12 19:23:37 +03:00
Elian Doran
a901e92573 chore(ckeditor5-footnotes): fix references: Element -> ModelElement 2025-07-12 19:23:33 +03:00
Elian Doran
6ead31b45f chore(ckeditor5-footnotes): fix references: RootElement -> ModelRootElement 2025-07-12 19:23:30 +03:00
Elian Doran
d4ce12dca9 chore(ckeditor5-footnotes): fix references: TextProxy -> ModelTextProxy 2025-07-12 19:23:25 +03:00
Elian Doran
bb6e22cdb7 chore(ckeditor5-footnotes): fix references: Text -> ModelText 2025-07-12 19:23:13 +03:00
Elian Doran
2c9fc4812e chore(deps): update dependency electron to v37.2.1 (#6303) 2025-07-12 19:05:18 +03:00
Elian Doran
60f4554afa Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-12 19:04:43 +03:00
Elian Doran
3c486bfd1b chore(ci): set personal access token for conflict checker 2025-07-12 19:04:41 +03:00
Elian Doran
26b9a95bb2 chore(deps): update svelte monorepo (#6316) 2025-07-12 19:00:30 +03:00
Elian Doran
f7c9217cea Table Collections: restyle (#6298) 2025-07-12 18:59:56 +03:00
Elian Doran
e92022b73c Tree Context Menu: relocate the "Duplicate subtree" menu item (#6299) 2025-07-12 18:52:35 +03:00
Elian Doran
61ff2353c8 docs(help): update documentation on duplicate subtree 2025-07-12 18:52:12 +03:00
Elian Doran
c8cca26ca4 Merge remote-tracking branch 'origin/main' into feat/tree-context-menu/relocate-duplicate-note-command 2025-07-12 18:41:58 +03:00
Elian Doran
aa556ed4d5 Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-12 18:40:28 +03:00
Elian Doran
5d694a7bdf chore(ci): permissions issue with merge checker 2025-07-12 18:40:25 +03:00
renovate[bot]
c4787dae23 fix(deps): update ckeditor monorepo 2025-07-12 10:57:11 +00:00
renovate[bot]
9f5f329c53 chore(deps): update dependency electron to v37.2.1 2025-07-12 10:55:36 +00:00
Elian Doran
f82b96fcc4 chore(deps): update dependency @types/node to v22.16.3 (#6302) 2025-07-12 13:53:21 +03:00
renovate[bot]
d4b24fa427 chore(deps): update svelte monorepo 2025-07-12 10:41:27 +00:00
renovate[bot]
c852f67c59 chore(deps): update dependency @types/node to v22.16.3 2025-07-12 10:39:43 +00:00
Elian Doran
92c228a3c9 chore(deps): update nx monorepo to v21.2.3 (#6306) 2025-07-12 13:17:34 +03:00
renovate[bot]
42f948e2b3 chore(deps): update nx monorepo to v21.2.3 2025-07-12 08:59:08 +00:00
Elian Doran
13e8932117 chore(deps): update dependency @playwright/test to v1.54.1 (#6308) 2025-07-12 11:55:42 +03:00
renovate[bot]
910d34bd42 chore(deps): update dependency @playwright/test to v1.54.1 2025-07-12 07:57:58 +00:00
Elian Doran
b204ba29e7 fix(deps): update eslint monorepo to v9.31.0 (#6311) 2025-07-12 10:54:00 +03:00
Elian Doran
d49244cbc8 Merge branch 'main' of https://github.com/TriliumNext/trilium 2025-07-12 10:08:35 +03:00
Elian Doran
ef2f2f17b4 feat(ci): mark PRs with merge conflicts 2025-07-12 10:08:33 +03:00
renovate[bot]
b9f21dcf4c fix(deps): update eslint monorepo to v9.31.0 2025-07-12 07:03:21 +00:00
Elian Doran
808fe690cc chore(deps): update dependency openai to v5.9.0 (#6309) 2025-07-12 10:01:18 +03:00
Elian Doran
901eec04e5 chore(deps): update dependency vite to v7.0.4 (#6305) 2025-07-12 10:01:02 +03:00
Elian Doran
9272394ada fix(deps): update dependency eslint-linter-browserify to v9.31.0 (#6310) 2025-07-12 09:59:19 +03:00
Elian Doran
4457982fae chore(deps): update ckeditor5 config packages to v12 (major) (#6312) 2025-07-12 09:59:05 +03:00
Elian Doran
7f67b2b461 chore(deps): update dependency dotenv to v17.2.0 (#6279) 2025-07-12 09:58:05 +03:00
Elian Doran
7f3934f4c3 chore(renovate): group svelte as monorepo 2025-07-12 09:57:29 +03:00
renovate[bot]
a3b80a2cc4 chore(deps): update ckeditor5 config packages to v12 2025-07-12 01:37:21 +00:00
renovate[bot]
6d967e5e51 fix(deps): update dependency eslint-linter-browserify to v9.31.0 2025-07-12 01:34:54 +00:00
renovate[bot]
b674ca90d1 chore(deps): update dependency openai to v5.9.0 2025-07-12 01:34:03 +00:00
renovate[bot]
95edb60a84 chore(deps): update dependency vite to v7.0.4 2025-07-12 01:29:46 +00:00
Adorian Doran
40add78ccb client/tree context menu: update translations 2025-07-12 02:22:00 +03:00
Adorian Doran
1029c24c06 client/tree context menu: relocate the "Duplicate subtree" menu item 2025-07-12 02:21:22 +03:00
Adorian Doran
94d94fe8fb Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/style/collections/table 2025-07-12 01:56:04 +03:00
Adorian Doran
49489c0f45 style/table collections: refactor 2025-07-12 01:55:07 +03:00
Adorian Doran
215833a2c9 style/table collections: tweak table footer 2025-07-12 01:40:18 +03:00
Adorian Doran
a7471a3d47 style/table collections: tweak checkbox cells 2025-07-12 01:34:22 +03:00
Adorian Doran
909aaefbd7 style/table collections: restyle context menus 2025-07-12 01:17:09 +03:00
Adorian Doran
ed758f4c92 style/table collections: tweak appearance 2025-07-11 22:39:49 +03:00
Adorian Doran
22300e8151 style/table collections: tweak appearance 2025-07-11 21:52:35 +03:00
Adorian Doran
18c55784c7 style/table collections: add a placeholder style for rows and cells 2025-07-11 00:16:15 +03:00
Adorian Doran
824a3c5fcc style/table collections: fix an issue with column headers 2025-07-10 21:54:32 +03:00
Adorian Doran
87da644027 style/table collections: add a placeholder style for column headers 2025-07-10 21:52:09 +03:00
Adorian Doran
4f42f543d8 style/table collections: create a stylesheet dedicated to the table view 2025-07-10 21:20:48 +03:00
renovate[bot]
aa872f47f2 chore(deps): update dependency dotenv to v17.2.0 2025-07-10 02:38:39 +00:00
410 changed files with 114725 additions and 20493 deletions

2
.github/FUNDING.yml vendored
View File

@@ -2,3 +2,5 @@
github: [eliandoran]
custom: ["https://paypal.me/eliandoran"]
liberapay: ElianDoran
buy_me_a_coffee: eliandoran

40
.github/instructions/nx.instructions.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

17
.github/workflows/checks.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Checks
on:
push:
pull_request_target:
types: [synchronize]
jobs:
main:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check if PRs have conflicts
uses: eps1lon/actions-label-merge-conflict@v3
with:
dirtyLabel: "merge-conflicts"
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"

View File

@@ -1,2 +1,2 @@
Adam Zivner <adam.zivner@gmail.com>
Adam Zivner <zadam.apps@gmail.com>
zadam <adam.zivner@gmail.com>
zadam <zadam.apps@gmail.com>

2
.nvmrc
View File

@@ -1 +1 @@
22.17.0
22.18.0

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9461/mcp"
}
}
}

10
.vscode/settings.json vendored
View File

@@ -28,5 +28,13 @@
"typescript.validate.enable": true,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
"typescript.enablePromptUseWorkspaceTsdk": true,
"search.exclude": {
"**/node_modules": true,
"docs/**/*.html": true,
"docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
},
"nxConsole.generateAiAgentRules": true
}

161
CLAUDE.md Normal file
View File

@@ -0,0 +1,161 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
## Development Commands
### Setup
- `pnpm install` - Install all dependencies
- `corepack enable` - Enable pnpm if not available
### Running Applications
- `pnpm run server:start` - Start development server (http://localhost:8080)
- `pnpm nx run server:serve` - Alternative server start command
- `pnpm nx run desktop:serve` - Run desktop Electron app
- `pnpm run server:start-prod` - Run server in production mode
### Building
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
- `pnpm run client:build` - Build client application
- `pnpm run server:build` - Build server application
- `pnpm run electron:build` - Build desktop application
### Testing
- `pnpm test:all` - Run all tests (parallel + sequential)
- `pnpm test:parallel` - Run tests that can run in parallel
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
- `pnpm nx test <project>` - Run tests for specific project
- `pnpm coverage` - Generate coverage reports
### Linting & Type Checking
- `pnpm nx run <project>:lint` - Lint specific project
- `pnpm nx run <project>:typecheck` - Type check specific project
## Architecture Overview
### Monorepo Structure
- **apps/**: Runnable applications
- `client/` - Frontend application (shared by server and desktop)
- `server/` - Node.js server with web interface
- `desktop/` - Electron desktop application
- `web-clipper/` - Browser extension for saving web content
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
- **packages/**: Shared libraries
- `commons/` - Shared interfaces and utilities
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
- `codemirror/` - Code editor customizations
- `highlightjs/` - Syntax highlighting
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
### Core Architecture Patterns
#### Three-Layer Cache System
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
#### Entity System
Core entities are defined in `apps/server/src/becca/entities/`:
- `BNote` - Notes with content and metadata
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
- `BAttribute` - Key-value metadata attached to notes
- `BRevision` - Note version history
- `BOption` - Application configuration
#### Widget-Based UI
Frontend uses a widget system (`apps/client/src/widgets/`):
- `BasicWidget` - Base class for all UI components
- `NoteContextAwareWidget` - Widgets that respond to note changes
- `RightPanelWidget` - Widgets displayed in the right panel
- Type-specific widgets in `type_widgets/` directory
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
### Key Files for Understanding Architecture
1. **Application Entry Points**:
- `apps/server/src/main.ts` - Server startup
- `apps/client/src/desktop.ts` - Client initialization
2. **Core Services**:
- `apps/server/src/becca/becca.ts` - Backend data management
- `apps/client/src/services/froca.ts` - Frontend data synchronization
- `apps/server/src/services/backend_script_api.ts` - Scripting API
3. **Database Schema**:
- `apps/server/src/assets/db/schema.sql` - Core database structure
4. **Configuration**:
- `nx.json` - NX workspace configuration
- `package.json` - Project dependencies and scripts
## Note Types and Features
Trilium supports multiple note types, each with specialized widgets:
- **Text**: Rich text with CKEditor5 (markdown import/export)
- **Code**: Syntax-highlighted code editing with CodeMirror
- **File**: Binary file attachments
- **Image**: Image display with editing capabilities
- **Canvas**: Drawing/diagramming with Excalidraw
- **Mermaid**: Diagram generation
- **Relation Map**: Visual note relationship mapping
- **Web View**: Embedded web pages
- **Doc/Book**: Hierarchical documentation structure
## Development Guidelines
### Testing Strategy
- Server tests run sequentially due to shared database
- Client tests can run in parallel
- E2E tests use Playwright for both server and desktop apps
- Build validation tests check artifact integrity
### Scripting System
Trilium provides powerful user scripting capabilities:
- Frontend scripts run in browser context
- Backend scripts run in Node.js context with full API access
- Script API documentation available in `docs/Script API/`
### Internationalization
- Translation files in `apps/client/src/translations/`
- Supported languages: English, German, Spanish, French, Romanian, Chinese
### Security Considerations
- Per-note encryption with granular protected sessions
- CSRF protection for API endpoints
- OpenID and TOTP authentication support
- Sanitization of user-generated content
## Common Development Tasks
### Adding New Note Types
1. Create widget in `apps/client/src/widgets/type_widgets/`
2. Register in `apps/client/src/services/note_types.ts`
3. Add backend handling in `apps/server/src/services/notes.ts`
### Extending Search
- Search expressions handled in `apps/server/src/services/search/`
- Add new search operators in search context files
### Custom CKEditor Plugins
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
## Build System Notes
- Uses NX for monorepo management with build caching
- Vite for fast development builds
- ESBuild for production optimization
- pnpm workspaces for dependency management
- Docker support with multi-stage builds

View File

@@ -1,9 +1,9 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran?style=flat-square)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes?style=flat-square)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total?style=flat-square)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop&style=flat-square)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
![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/)
[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)
@@ -115,12 +115,20 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
## 💻 Contribute
### Translations
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
Here's the language coverage we have so far:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
```shell
git clone https://github.com/TriliumNext/Notes.git
cd Notes
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
@@ -129,8 +137,8 @@ pnpm run server:start
Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:
```shell
git clone https://github.com/TriliumNext/Notes.git
cd Notes
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx run edit-docs:edit-docs
```
@@ -138,8 +146,8 @@ pnpm nx run edit-docs:edit-docs
### Building the Executable
Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows:
```shell
git clone https://github.com/TriliumNext/Notes.git
cd Notes
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```

View File

@@ -35,13 +35,13 @@
"chore:generate-openapi": "tsx bin/generate-openapi.js"
},
"devDependencies": {
"@playwright/test": "1.53.2",
"@stylistic/eslint-plugin": "5.1.0",
"@playwright/test": "1.54.2",
"@stylistic/eslint-plugin": "5.2.2",
"@types/express": "5.0.3",
"@types/node": "22.16.2",
"@types/node": "22.17.0",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.30.1",
"eslint": "9.32.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",
@@ -49,7 +49,7 @@
"rcedit": "4.0.1",
"rimraf": "6.0.1",
"tslib": "2.8.1",
"typedoc": "0.28.7",
"typedoc": "0.28.9",
"typedoc-plugin-missing-exports": "4.0.0"
},
"optionalDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.96.0",
"version": "0.97.2",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -10,7 +10,7 @@
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.30.1",
"@eslint/js": "9.32.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.18",
"@fullcalendar/daygrid": "6.1.18",
@@ -18,6 +18,7 @@
"@fullcalendar/list": "6.1.18",
"@fullcalendar/multimonth": "6.1.18",
"@fullcalendar/timegrid": "6.1.18",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.1.8",
"@mind-elixir/node-menu": "5.0.0",
"@popperjs/core": "2.11.8",
@@ -38,7 +39,6 @@
"i18next": "25.3.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.22",
@@ -46,29 +46,29 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "16.0.0",
"mermaid": "11.8.1",
"mind-elixir": "5.0.1",
"marked": "16.1.1",
"mermaid": "11.9.0",
"mind-elixir": "5.0.4",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.26.9",
"preact": "10.27.0",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "4.1.0",
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.32",
"@types/leaflet": "1.9.20",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.2.7",
"@types/tabulator-tables": "6.2.9",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.0"
"vite-plugin-static-copy": "3.1.1"
},
"nx": {
"name": "client",

View File

@@ -28,6 +28,8 @@ import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { StartupChecks } from "./startup_checks.js";
import type { CreateNoteOpts } from "../services/note_create.js";
import { ColumnComponent } from "tabulator-tables";
interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget;
@@ -131,6 +133,8 @@ export type CommandMappings = {
hideLeftPane: CommandData;
showCpuArchWarning: CommandData;
showLeftPane: CommandData;
showAttachments: CommandData;
showSearchHistory: CommandData;
hoistNote: CommandData & { noteId: string };
leaveProtectedSession: CommandData;
enterProtectedSession: CommandData;
@@ -171,7 +175,7 @@ export type CommandMappings = {
deleteNotes: ContextMenuCommandData;
importIntoNote: ContextMenuCommandData;
exportNote: ContextMenuCommandData;
searchInSubtree: ContextMenuCommandData;
searchInSubtree: CommandData & { notePath: string; };
moveNoteUp: ContextMenuCommandData;
moveNoteDown: ContextMenuCommandData;
moveNoteUpInHierarchy: ContextMenuCommandData;
@@ -260,6 +264,73 @@ export type CommandMappings = {
closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData;
commandPalette: CommandData;
// Keyboard shortcuts
backInNoteHistory: CommandData;
forwardInNoteHistory: CommandData;
forceSaveRevision: CommandData;
scrollToActiveNote: CommandData;
quickSearch: CommandData;
collapseTree: CommandData;
createNoteAfter: CommandData;
createNoteInto: CommandData;
addNoteAboveToSelection: CommandData;
addNoteBelowToSelection: CommandData;
openNewTab: CommandData;
activateNextTab: CommandData;
activatePreviousTab: CommandData;
openNewWindow: CommandData;
toggleTray: CommandData;
firstTab: CommandData;
secondTab: CommandData;
thirdTab: CommandData;
fourthTab: CommandData;
fifthTab: CommandData;
sixthTab: CommandData;
seventhTab: CommandData;
eigthTab: CommandData;
ninthTab: CommandData;
lastTab: CommandData;
showNoteSource: CommandData;
showSQLConsole: CommandData;
showBackendLog: CommandData;
showCheatsheet: CommandData;
showHelp: CommandData;
addLinkToText: CommandData;
followLinkUnderCursor: CommandData;
insertDateTimeToText: CommandData;
pasteMarkdownIntoText: CommandData;
cutIntoNote: CommandData;
addIncludeNoteToText: CommandData;
editReadOnlyNote: CommandData;
toggleRibbonTabClassicEditor: CommandData;
toggleRibbonTabBasicProperties: CommandData;
toggleRibbonTabBookProperties: CommandData;
toggleRibbonTabFileProperties: CommandData;
toggleRibbonTabImageProperties: CommandData;
toggleRibbonTabOwnedAttributes: CommandData;
toggleRibbonTabInheritedAttributes: CommandData;
toggleRibbonTabPromotedAttributes: CommandData;
toggleRibbonTabNoteMap: CommandData;
toggleRibbonTabNoteInfo: CommandData;
toggleRibbonTabNotePaths: CommandData;
toggleRibbonTabSimilarNotes: CommandData;
toggleRightPane: CommandData;
printActiveNote: CommandData;
exportAsPdf: CommandData;
openNoteExternally: CommandData;
renderActiveNote: CommandData;
unhoist: CommandData;
reloadFrontendApp: CommandData;
openDevTools: CommandData;
findInText: CommandData;
toggleLeftPane: CommandData;
toggleFullscreen: CommandData;
zoomOut: CommandData;
zoomIn: CommandData;
zoomReset: CommandData;
copyWithoutFormatting: CommandData;
// Geomap
deleteFromMap: { noteId: string };
@@ -276,6 +347,21 @@ export type CommandMappings = {
geoMapCreateChildNote: CommandData;
// Table view
addNewRow: CommandData & {
customOpts: CreateNoteOpts;
parentNotePath?: string;
};
addNewTableColumn: CommandData & {
columnToEdit?: ColumnComponent;
referenceColumn?: ColumnComponent;
direction?: "before" | "after";
type?: "label" | "relation";
};
deleteTableColumn: CommandData & {
columnToDelete?: ColumnComponent;
};
buildTouchBar: CommandData & {
TouchBar: typeof TouchBar;
buildIcon(name: string): NativeImage;

View File

@@ -30,13 +30,6 @@ interface CreateChildrenResponse {
export default class Entrypoints extends Component {
constructor() {
super();
if (jQuery.hotkeys) {
// hot keys are active also inside inputs and content editables
jQuery.hotkeys.options.filterInputAcceptingElements = false;
jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
}
}
openDevToolsCommand() {
@@ -113,7 +106,9 @@ export default class Entrypoints extends Component {
if (win.isFullScreenable()) {
win.setFullScreen(!win.isFullScreen());
}
} // outside of electron this is handled by the browser
} else {
document.documentElement.requestFullscreen();
}
}
reloadFrontendAppCommand() {

View File

@@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
// Some book types must always display a note list, even if no children.
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
// Collections must always display a note list, even if no children.
const viewType = note.getLabelValue("viewType") ?? "grid";
if (!["list", "grid"].includes(viewType)) {
return true;
}

View File

@@ -13,7 +13,6 @@ import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "jquery-hotkeys";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();

View File

@@ -256,6 +256,20 @@ class FNote {
return this.children;
}
async getSubtreeNoteIds() {
let noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) {
noteIds.push(child.noteId);
noteIds.push(await child.getSubtreeNoteIds());
}
return noteIds.flat();
}
async getSubtreeNotes() {
const noteIds = await this.getSubtreeNoteIds();
return this.froca.getNotes(noteIds);
}
async getChildNotes() {
return await this.froca.getNotes(this.children);
}

View File

@@ -26,6 +26,11 @@ export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
/**
* The icon to display in the menu item.
*
* If not set, no icon is displayed and the item will appear shifted slightly to the left if there are other items with icons. To avoid this, use `bx bx-empty`.
*/
uiIcon?: string;
badges?: MenuItemBadge[];
templateNoteId?: string;

View File

@@ -23,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null;
// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator,
// so they need to be added manually.
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog" | "searchInSubtree";
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
private treeWidget: NoteTreeWidget;
@@ -129,12 +129,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
},
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
{
title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`,
command: "duplicateSubtree",
uiIcon: "bx bx-outline",
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
},
{ title: "----" },
@@ -188,6 +182,13 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
{
title: `${t("tree-context-menu.duplicate")} <kbd data-command="duplicateSubtree">`,
command: "duplicateSubtree",
uiIcon: "bx bx-outline",
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
},
{
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
command: "deleteNotes",

View File

@@ -79,7 +79,19 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b
return $container;
}
const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"];
const HIDDEN_ATTRIBUTES = [
"originalFileName",
"fileSize",
"template",
"inherit",
"cssClass",
"iconClass",
"pageSize",
"viewType",
"geolocation",
"docName",
"webViewSrc"
];
async function renderNormalAttributes(note: FNote) {
const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();

View File

@@ -12,11 +12,12 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
});
}
export async function setLabel(noteId: string, name: string, value: string = "") {
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
await server.put(`notes/${noteId}/set-attribute`, {
type: "label",
name: name,
value: value
value: value,
isInheritable
});
}

View File

@@ -95,7 +95,15 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
}
}
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false) {
/**
* Shows the delete confirmation screen
*
* @param branchIdsToDelete the list of branch IDs to delete.
* @param forceDeleteAllClones whether to check by default the "Delete also all clones" checkbox.
* @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s).
* @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded.
*/
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) {
branchIdsToDelete = filterRootNote(branchIdsToDelete);
if (branchIdsToDelete.length === 0) {
@@ -110,10 +118,12 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
return false;
}
try {
await activateParentNotePath();
} catch (e) {
console.error(e);
if (moveToParent) {
try {
await activateParentNotePath();
} catch (e) {
console.error(e);
}
}
const taskId = utils.randomString(10);

View File

@@ -15,6 +15,8 @@ import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation
import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
import { t } from "./i18n.js";
import type FNote from "../entities/fnote.js";
import toast from "./toast.js";
import { BulkAction } from "@triliumnext/commons";
const ACTION_GROUPS = [
{
@@ -89,6 +91,17 @@ function parseActions(note: FNote) {
.filter((action) => !!action);
}
export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) {
await server.post("bulk-action/execute", {
noteIds: targetNoteIds,
includeDescendants,
actions
});
await ws.waitForMaxKnownEntityChangeId();
toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
}
export default {
addAction,
parseActions,

View File

@@ -0,0 +1,295 @@
import { ActionKeyboardShortcut } from "@triliumnext/commons";
import appContext, { type CommandNames } from "../components/app_context.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import { t, translationsInitializedPromise } from "./i18n.js";
import keyboardActions from "./keyboard_actions.js";
import utils from "./utils.js";
export interface CommandDefinition {
id: string;
name: string;
description?: string;
icon?: string;
shortcut?: string;
commandName?: CommandNames;
handler?: () => Promise<unknown> | null | undefined | void;
aliases?: string[];
source?: "manual" | "keyboard-action";
/** Reference to the original keyboard action for scope checking. */
keyboardAction?: ActionKeyboardShortcut;
}
class CommandRegistry {
private commands: Map<string, CommandDefinition> = new Map();
private aliases: Map<string, string> = new Map();
constructor() {
this.loadCommands();
}
private async loadCommands() {
await translationsInitializedPromise;
this.registerDefaultCommands();
await this.loadKeyboardActionsAsync();
}
private registerDefaultCommands() {
this.register({
id: "export-note",
name: t("command_palette.export_note_title"),
description: t("command_palette.export_note_description"),
icon: "bx bx-export",
handler: () => {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
appContext.triggerCommand("showExportDialog", {
notePath,
defaultType: "single"
});
}
}
});
this.register({
id: "show-attachments",
name: t("command_palette.show_attachments_title"),
description: t("command_palette.show_attachments_description"),
icon: "bx bx-paperclip",
handler: () => appContext.triggerCommand("showAttachments")
});
// Special search commands with custom logic
this.register({
id: "search-notes",
name: t("command_palette.search_notes_title"),
description: t("command_palette.search_notes_description"),
icon: "bx bx-search",
handler: () => appContext.triggerCommand("searchNotes", {})
});
this.register({
id: "search-in-subtree",
name: t("command_palette.search_subtree_title"),
description: t("command_palette.search_subtree_description"),
icon: "bx bx-search-alt",
handler: () => {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
appContext.triggerCommand("searchInSubtree", { notePath });
}
}
});
this.register({
id: "show-search-history",
name: t("command_palette.search_history_title"),
description: t("command_palette.search_history_description"),
icon: "bx bx-history",
handler: () => appContext.triggerCommand("showSearchHistory")
});
this.register({
id: "show-launch-bar",
name: t("command_palette.configure_launch_bar_title"),
description: t("command_palette.configure_launch_bar_description"),
icon: "bx bx-sidebar",
handler: () => appContext.triggerCommand("showLaunchBarSubtree")
});
}
private async loadKeyboardActionsAsync() {
try {
const actions = await keyboardActions.getActions();
this.registerKeyboardActions(actions);
} catch (error) {
console.error("Failed to load keyboard actions:", error);
}
}
private registerKeyboardActions(actions: ActionKeyboardShortcut[]) {
for (const action of actions) {
// Skip actions that we've already manually registered
if (this.commands.has(action.actionName)) {
continue;
}
// Skip actions that don't have a description (likely separators)
if (!action.description) {
continue;
}
// Skip Electron-only actions if not in Electron environment
if (action.isElectronOnly && !utils.isElectron()) {
continue;
}
// Skip actions that should not appear in the command palette
if (action.ignoreFromCommandPalette) {
continue;
}
// Get the primary shortcut (first one in the list)
const primaryShortcut = action.effectiveShortcuts?.[0];
let name = action.friendlyName;
if (action.scope === "note-tree") {
name = t("command_palette.tree-action-name", { name: action.friendlyName });
}
// Create a command definition from the keyboard action
const commandDef: CommandDefinition = {
id: action.actionName,
name,
description: action.description,
icon: action.iconClass,
shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined,
commandName: action.actionName as CommandNames,
source: "keyboard-action",
keyboardAction: action
};
this.register(commandDef);
}
}
private formatShortcut(shortcut: string): string {
// Convert electron accelerator format to display format
return shortcut
.replace(/CommandOrControl/g, 'Ctrl')
.replace(/\+/g, ' + ');
}
register(command: CommandDefinition) {
this.commands.set(command.id, command);
// Register aliases
if (command.aliases) {
for (const alias of command.aliases) {
this.aliases.set(alias.toLowerCase(), command.id);
}
}
}
getCommand(id: string): CommandDefinition | undefined {
return this.commands.get(id);
}
getAllCommands(): CommandDefinition[] {
const commands = Array.from(this.commands.values());
// Sort commands by name
commands.sort((a, b) => a.name.localeCompare(b.name));
return commands;
}
searchCommands(query: string): CommandDefinition[] {
const normalizedQuery = query.toLowerCase();
const results: { command: CommandDefinition; score: number }[] = [];
for (const command of this.commands.values()) {
let score = 0;
// Exact match on name
if (command.name.toLowerCase() === normalizedQuery) {
score = 100;
}
// Name starts with query
else if (command.name.toLowerCase().startsWith(normalizedQuery)) {
score = 80;
}
// Name contains query
else if (command.name.toLowerCase().includes(normalizedQuery)) {
score = 60;
}
// Description contains query
else if (command.description?.toLowerCase().includes(normalizedQuery)) {
score = 40;
}
// Check aliases
else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) {
score = 50;
}
if (score > 0) {
results.push({ command, score });
}
}
// Sort by score (highest first) and then by name
results.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score;
}
return a.command.name.localeCompare(b.command.name);
});
return results.map(r => r.command);
}
async executeCommand(commandId: string) {
const command = this.getCommand(commandId);
if (!command) {
console.error(`Command not found: ${commandId}`);
return;
}
// Execute custom handler if provided
if (command.handler) {
await command.handler();
return;
}
// Handle keyboard action with scope-aware execution
if (command.keyboardAction && command.commandName) {
if (command.keyboardAction.scope === "note-tree") {
this.executeWithNoteTreeFocus(command.commandName);
} else if (command.keyboardAction.scope === "text-detail") {
this.executeWithTextDetail(command.commandName);
} else {
appContext.triggerCommand(command.commandName, {
ntxId: appContext.tabManager.activeNtxId
});
}
return;
}
// Fallback for commands without keyboard action reference
if (command.commandName) {
appContext.triggerCommand(command.commandName, {
ntxId: appContext.tabManager.activeNtxId
});
return;
}
console.error(`Command ${commandId} has no handler or commandName`);
}
private executeWithNoteTreeFocus(actionName: CommandNames) {
const tree = document.querySelector(".tree-wrapper") as HTMLElement;
if (!tree) {
return;
}
const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget;
const activeNode = treeComponent.getActiveNode();
treeComponent.triggerCommand(actionName, {
ntxId: appContext.tabManager.activeNtxId,
node: activeNode
});
}
private async executeWithTextDetail(actionName: CommandNames) {
const typeWidget = await appContext.tabManager.getActiveContext()?.getTypeWidget();
if (!typeWidget) {
return;
}
typeWidget.triggerCommand(actionName, {
ntxId: appContext.tabManager.activeNtxId
});
}
}
const commandRegistry = new CommandRegistry();
export default commandRegistry;

View File

@@ -65,6 +65,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
} else if (entity instanceof FNote) {
$renderedContent
.css("display", "flex")
.css("flex-direction", "column");
$renderedContent.append(
$("<div>")
.css("display", "flex")
@@ -72,8 +75,33 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
.css("align-items", "center")
.css("height", "100%")
.css("font-size", "500%")
.css("flex-grow", "1")
.append($("<span>").addClass(entity.getIcon()))
);
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
const $footer = $("<footer>")
.addClass("webview-footer");
const $openButton = $(`
<button class="file-open btn btn-primary" type="button">
<span class="bx bx-link-external"></span>
${t("content_renderer.open_externally")}
</button>
`)
.appendTo($footer)
.on("click", () => {
const webViewSrc = entity.getLabelValue("webViewSrc");
if (webViewSrc) {
if (utils.isElectron()) {
const electron = utils.dynamicRequire("electron");
electron.shell.openExternal(webViewSrc);
} else {
window.open(webViewSrc, '_blank', 'noopener,noreferrer');
}
}
});
$footer.appendTo($renderedContent);
}
}
if (entity instanceof FNote) {

View File

@@ -41,8 +41,14 @@ async function info(message: string) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
}
/**
* Displays a confirmation dialog with the given message.
*
* @param message the message to display in the dialog.
* @returns A promise that resolves to true if the user confirmed, false otherwise.
*/
async function confirm(message: string) {
return new Promise((res) =>
return new Promise<boolean>((res) =>
appContext.triggerCommand("showConfirmDialog", <ConfirmWithMessageOptions>{
message,
callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed)

View File

@@ -6,6 +6,11 @@ import type { Locale } from "@triliumnext/commons";
let locales: Locale[] | null;
/**
* A deferred promise that resolves when translations are initialized.
*/
export let translationsInitializedPromise = $.Deferred();
export async function initLocale() {
const locale = (options.get("locale") as string) || "en";
@@ -19,6 +24,8 @@ export async function initLocale() {
},
returnEmptyString: false
});
translationsInitializedPromise.resolve();
}
export function getAvailableLocales() {

View File

@@ -2,21 +2,15 @@ import server from "./server.js";
import appContext, { type CommandNames } from "../components/app_context.js";
import shortcutService from "./shortcuts.js";
import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
const keyboardActionRepo: Record<string, Action> = {};
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
// TODO: Deduplicate with server.
export interface Action {
actionName: CommandNames;
effectiveShortcuts: string[];
scope: string;
}
const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => {
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
actions = actions.filter((a) => !!a.actionName); // filter out separators
for (const action of actions) {
action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:"));
action.effectiveShortcuts = (action.effectiveShortcuts ?? []).filter((shortcut) => !shortcut.startsWith("global:"));
keyboardActionRepo[action.actionName] = action;
}
@@ -38,7 +32,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
const actions = await getActionsForScope(scope);
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts) {
for (const shortcut of action.effectiveShortcuts ?? []) {
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
}
}
@@ -46,7 +40,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
getActionsForScope("window").then((actions) => {
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts) {
for (const shortcut of action.effectiveShortcuts ?? []) {
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
}
}
@@ -80,7 +74,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
const action = await getAction(actionName, true);
if (action) {
const keyboardActions = action.effectiveShortcuts.join(", ");
const keyboardActions = (action.effectiveShortcuts ?? []).join(", ");
if (keyboardActions || $(el).text() !== "not set") {
$(el).text(keyboardActions);
@@ -99,7 +93,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
if (action) {
const title = $(el).attr("title");
const shortcuts = action.effectiveShortcuts.join(", ");
const shortcuts = (action.effectiveShortcuts ?? []).join(", ");
if (title?.includes(shortcuts)) {
return;

View File

@@ -316,7 +316,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
if (notePath) {
if (openInPopup) {
if (isLeftClick && openInPopup) {
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
} else if (openInNewWindow) {
appContext.triggerCommand("openInWindow", { notePath, viewScope });
@@ -405,7 +405,7 @@ function linkContextMenu(e: PointerEvent) {
linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
}
export async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
const $link = $el[0].tagName === "A" ? $el : $el.find("a");
href = href || $link.attr("href");

View File

@@ -3,6 +3,7 @@ import appContext from "../components/app_context.js";
import noteCreateService from "./note_create.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import commandRegistry from "./command_registry.js";
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
// this key needs to have this value, so it's hit by the tooltip
@@ -29,9 +30,12 @@ export interface Suggestion {
notePathTitle?: string;
notePath?: string;
highlightedNotePathTitle?: string;
action?: string | "create-note" | "search-notes" | "external-link";
action?: string | "create-note" | "search-notes" | "external-link" | "command";
parentNoteId?: string;
icon?: string;
commandId?: string;
commandDescription?: string;
commandShortcut?: string;
}
interface Options {
@@ -40,7 +44,12 @@ interface Options {
allowCreatingNotes?: boolean;
allowJumpToSearchNotes?: boolean;
allowExternalLinks?: boolean;
/** If set, hides the right-side button corresponding to go to selected note. */
hideGoToSelectedNoteButton?: boolean;
/** If set, hides all right-side buttons in the autocomplete dropdown */
hideAllButtons?: boolean;
/** If set, enables command palette mode */
isCommandPalette?: boolean;
}
async function autocompleteSourceForCKEditor(queryText: string) {
@@ -70,6 +79,31 @@ async function autocompleteSourceForCKEditor(queryText: string) {
}
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) {
// Check if we're in command mode
if (options.isCommandPalette && term.startsWith(">")) {
const commandQuery = term.substring(1).trim();
// Get commands (all if no query, filtered if query provided)
const commands = commandQuery.length === 0
? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
action: "command",
commandId: cmd.id,
noteTitle: cmd.name,
notePathTitle: `>${cmd.name}`,
highlightedNotePathTitle: cmd.name,
commandDescription: cmd.description,
commandShortcut: cmd.shortcut,
icon: cmd.icon
}));
cb(commandSuggestions);
return;
}
const fastSearch = options.fastSearch === false ? false : true;
if (fastSearch === false) {
if (term.trim().length === 0) {
@@ -143,6 +177,12 @@ function showRecentNotes($el: JQuery<HTMLElement>) {
$el.trigger("focus");
}
function showAllCommands($el: JQuery<HTMLElement>) {
searchDelay = 0;
$el.setSelectedNotePath("");
$el.autocomplete("val", ">").autocomplete("open");
}
function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
const searchString = $el.autocomplete("val") as unknown as string;
if (options.fastSearch === false || searchString?.trim().length === 0) {
@@ -190,9 +230,11 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
const $goToSelectedNoteButton = $("<a>").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right");
$el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
if (!options.hideAllButtons) {
$el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
}
if (!options.hideGoToSelectedNoteButton) {
if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) {
$el.after($goToSelectedNoteButton);
}
@@ -265,7 +307,24 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
},
displayKey: "notePathTitle",
templates: {
suggestion: (suggestion) => `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`
suggestion: (suggestion) => {
if (suggestion.action === "command") {
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
if (suggestion.commandDescription) {
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
}
html += `</div>`;
if (suggestion.commandShortcut) {
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
}
html += '</div>';
return html;
}
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
cache: false
@@ -275,6 +334,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
if (suggestion.action === "command") {
$el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]);
return;
}
if (suggestion.action === "external-link") {
$el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink);
@@ -391,6 +456,7 @@ export default {
autocompleteSourceForCKEditor,
initNoteAutocomplete,
showRecentNotes,
showAllCommands,
setText,
init
};

View File

@@ -11,7 +11,7 @@ import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
interface CreateNoteOpts {
export interface CreateNoteOpts {
isProtected?: boolean;
saveSelection?: boolean;
title?: string | null;

View File

@@ -1,4 +1,5 @@
import type FNote from "../entities/fnote.js";
import BoardView from "../widgets/view_widgets/board_view/index.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
@@ -6,39 +7,25 @@ import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
export type ViewTypeOptions = typeof allViewTypes[number];
export default class NoteListRenderer {
private viewType: ViewTypeOptions;
public viewMode: ViewMode<any> | null;
private args: ArgsWithoutNoteId;
public viewMode?: ViewMode<any>;
constructor(args: ViewModeArgs) {
constructor(args: ArgsWithoutNoteId) {
this.args = args;
this.viewType = this.#getViewType(args.parentNote);
switch (this.viewType) {
case "list":
case "grid":
this.viewMode = new ListOrGridView(this.viewType, args);
break;
case "calendar":
this.viewMode = new CalendarView(args);
break;
case "table":
this.viewMode = new TableView(args);
break;
case "geoMap":
this.viewMode = new GeoView(args);
break;
default:
this.viewMode = null;
}
}
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {
@@ -47,15 +34,38 @@ export default class NoteListRenderer {
}
get isFullHeight() {
return this.viewMode?.isFullHeight;
switch (this.viewType) {
case "list":
case "grid":
return false;
default:
return true;
}
}
async renderList() {
if (!this.viewMode) {
return null;
}
const args = this.args;
const viewMode = this.#buildViewMode(args);
this.viewMode = viewMode;
await viewMode.beforeRender();
return await viewMode.renderList();
}
return await this.viewMode.renderList();
#buildViewMode(args: ViewModeArgs) {
switch (this.viewType) {
case "calendar":
return new CalendarView(args);
case "table":
return new TableView(args);
case "geoMap":
return new GeoView(args);
case "board":
return new BoardView(args);
case "list":
case "grid":
default:
return new ListOrGridView(this.viewType, args);
}
}
}

View File

@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
let dismissTimer: ReturnType<typeof setTimeout>;
function setupGlobalTooltip() {
$(document).on("mouseenter", "a", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
$(document).on("click", (e) => {

View File

@@ -1,4 +1,4 @@
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
type Multiplicity = "single" | "multi";
export interface DefinitionObject {
@@ -17,7 +17,7 @@ function parse(value: string) {
for (const token of tokens) {
if (token === "promoted") {
defObj.isPromoted = true;
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
defObj.labelType = token as LabelType;
} else if (["single", "multi"].includes(token)) {
defObj.multiplicity = token as Multiplicity;

View File

@@ -0,0 +1,323 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
default: {
isDesktop: () => true
}
}));
// Mock jQuery globally since it's used in the shortcuts module
const mockElement = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
const mockJQuery = vi.fn(() => [mockElement]);
(mockJQuery as any).length = 1;
mockJQuery[0] = mockElement;
(global as any).$ = mockJQuery as any;
global.document = mockElement as any;
describe("shortcuts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Clean up any active bindings after each test
shortcuts.removeGlobalShortcut("test-namespace");
});
describe("normalizeShortcut", () => {
it("should normalize shortcut to lowercase and remove whitespace", () => {
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
});
it("should handle empty or null shortcuts", () => {
expect(shortcuts.normalizeShortcut("")).toBe("");
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
});
it("should handle shortcuts with multiple spaces", () => {
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
});
it("should warn about malformed shortcuts", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
shortcuts.normalizeShortcut("ctrl+");
shortcuts.normalizeShortcut("+a");
shortcuts.normalizeShortcut("ctrl++a");
expect(consoleSpy).toHaveBeenCalledTimes(3);
consoleSpy.mockRestore();
});
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
const event = createKeyboardEvent("a", "KeyA");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "A")).toBe(true);
});
it("should match number keys using digit codes", () => {
const event = createKeyboardEvent("1", "Digit1");
expect(keyMatches(event, "1")).toBe(true);
});
it("should match special keys using key mapping", () => {
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
});
it("should match function keys", () => {
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
});
it("should handle undefined or null keys", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("matchesShortcut", () => {
const createKeyboardEvent = (options: {
key: string;
code?: string;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
}) => ({
key: options.key,
code: options.code || `Key${options.key.toUpperCase()}`,
ctrlKey: options.ctrlKey || false,
altKey: options.altKey || false,
shiftKey: options.shiftKey || false,
metaKey: options.metaKey || false
} as KeyboardEvent);
it("should match simple key shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(true);
});
it("should match shortcuts with modifiers", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
});
it("should match complex modifier combinations", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyA",
ctrlKey: true,
shiftKey: true
});
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
});
it("should not match when modifiers don't match", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "alt+a")).toBe(false);
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should handle alternative modifier names", () => {
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
});
it("should handle empty or invalid shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "")).toBe(false);
expect(matchesShortcut(event, null as any)).toBe(false);
});
it("should handle invalid events", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(matchesShortcut(null as any, "a")).toBe(false);
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should warn about invalid shortcut formats", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
matchesShortcut(event, "ctrl+");
matchesShortcut(event, "+");
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("bindGlobalShortcut", () => {
it("should bind a global shortcut", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should not bind shortcuts when handler is null", () => {
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
expect(mockElement.addEventListener).not.toHaveBeenCalled();
});
it("should remove previous bindings when namespace is reused", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
});
});
describe("bindElShortcut", () => {
it("should bind shortcut to specific element", () => {
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const mockJQueryEl = [mockEl] as any;
mockJQueryEl.length = 1;
const handler = vi.fn();
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should fall back to document when element is empty", () => {
const emptyJQuery = [] as any;
emptyJQuery.length = 0;
const handler = vi.fn();
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("removeGlobalShortcut", () => {
it("should remove shortcuts for a specific namespace", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
shortcuts.removeGlobalShortcut("test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("event handling", () => {
it.skip("should call handler when shortcut matches", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
// Get the listener that was registered
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
const [, listener] = mockElement.addEventListener.mock.calls[0];
// First verify that matchesShortcut works directly
const testEvent = {
type: "keydown",
key: "a",
code: "KeyA",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
// Test matchesShortcut directly first
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
// Now test the actual listener
listener(testEvent);
expect(handler).toHaveBeenCalled();
expect(testEvent.preventDefault).toHaveBeenCalled();
expect(testEvent.stopPropagation).toHaveBeenCalled();
});
it("should not call handler for non-keyboard events", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-keyboard event
const event = {
type: "click"
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
});
it("should not call handler when shortcut doesn't match", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-matching keydown event
const event = {
type: "keydown",
key: "b",
code: "KeyB",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,18 @@
import utils from "./utils.js";
type ElementType = HTMLElement | Document;
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
type Handler = (e: KeyboardEvent) => void;
interface ShortcutBinding {
element: HTMLElement | Document;
shortcut: string;
handler: Handler;
namespace: string | null;
listener: (evt: Event) => void;
}
// Store all active shortcut bindings for management
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
function removeGlobalShortcut(namespace: string) {
bindGlobalShortcut("", null, namespace);
@@ -15,38 +26,167 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
if (utils.isDesktop()) {
keyboardShortcut = normalizeShortcut(keyboardShortcut);
let eventName = "keydown";
// If namespace is provided, remove all previous bindings for this namespace
if (namespace) {
eventName += `.${namespace}`;
// if there's a namespace, then we replace the existing event handler with the new one
$el.off(eventName);
removeNamespaceBindings(namespace);
}
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut) {
$el.bind(eventName, keyboardShortcut, (e) => {
if (handler) {
handler(e);
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut && handler) {
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
const listener = (evt: Event) => {
// Only handle keyboard events
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
return;
}
e.preventDefault();
e.stopPropagation();
});
const e = evt as KeyboardEvent;
if (matchesShortcut(e, keyboardShortcut)) {
e.preventDefault();
e.stopPropagation();
handler(e);
}
};
// Add the event listener
element.addEventListener('keydown', listener);
// Store the binding for later cleanup
const binding: ShortcutBinding = {
element,
shortcut: keyboardShortcut,
handler,
namespace,
listener
};
const key = namespace || 'global';
if (!activeBindings.has(key)) {
activeBindings.set(key, []);
}
activeBindings.get(key)!.push(binding);
}
}
}
function removeNamespaceBindings(namespace: string) {
const bindings = activeBindings.get(namespace);
if (bindings) {
// Remove all event listeners for this namespace
bindings.forEach(binding => {
binding.element.removeEventListener('keydown', binding.listener);
});
activeBindings.delete(namespace);
}
}
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
if (!shortcut) return false;
// Ensure we have a proper KeyboardEvent with key property
if (!e || typeof e.key !== 'string') {
console.warn('matchesShortcut called with invalid event:', e);
return false;
}
const parts = shortcut.toLowerCase().split('+');
const key = parts[parts.length - 1]; // Last part is the actual key
const modifiers = parts.slice(0, -1); // Everything before is modifiers
// Defensive check - ensure we have a valid key
if (!key || key.trim() === '') {
console.warn('Invalid shortcut format:', shortcut);
return false;
}
// Check if the main key matches
if (!keyMatches(e, key)) {
return false;
}
// Check modifiers
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
const expectedAlt = modifiers.includes('alt');
const expectedShift = modifiers.includes('shift');
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
return e.ctrlKey === expectedCtrl &&
e.altKey === expectedAlt &&
e.shiftKey === expectedShift &&
e.metaKey === expectedMeta;
}
export function keyMatches(e: KeyboardEvent, key: string): boolean {
// Defensive check for undefined/null key
if (!key) {
console.warn('keyMatches called with undefined/null key');
return false;
}
// Handle special key mappings and aliases
const keyMap: { [key: string]: string[] } = {
'return': ['Enter'],
'enter': ['Enter'], // alias for return
'del': ['Delete'],
'delete': ['Delete'], // alias for del
'esc': ['Escape'],
'escape': ['Escape'], // alias for esc
'space': [' ', 'Space'],
'tab': ['Tab'],
'backspace': ['Backspace'],
'home': ['Home'],
'end': ['End'],
'pageup': ['PageUp'],
'pagedown': ['PageDown'],
'up': ['ArrowUp'],
'down': ['ArrowDown'],
'left': ['ArrowLeft'],
'right': ['ArrowRight']
};
// Function keys
for (let i = 1; i <= 19; i++) {
keyMap[`f${i}`] = [`F${i}`];
}
const mappedKeys = keyMap[key.toLowerCase()];
if (mappedKeys) {
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
}
// For number keys, use the physical key code regardless of modifiers
// This works across all keyboard layouts
if (key >= '0' && key <= '9') {
return e.code === `Digit${key}`;
}
// For letter keys, use the physical key code for consistency
if (key.length === 1 && key >= 'a' && key <= 'z') {
return e.code === `Key${key.toUpperCase()}`;
}
// For regular keys, check both key and code as fallback
return e.key.toLowerCase() === key.toLowerCase() ||
e.code.toLowerCase() === key.toLowerCase();
}
/**
* Normalize to the form expected by the jquery.hotkeys.js
* Simple normalization - just lowercase and trim whitespace
*/
function normalizeShortcut(shortcut: string): string {
if (!shortcut) {
return shortcut;
}
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
// Warn about potentially problematic shortcuts
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
}
return normalized;
}
export default {

View File

@@ -51,6 +51,14 @@ export default class SpacedUpdate {
this.lastUpdated = Date.now();
}
/**
* Sets the update interval for the spaced update.
* @param interval The update interval in milliseconds.
*/
setUpdateInterval(interval: number) {
this.updateInterval = interval;
}
triggerUpdate() {
if (!this.changed) {
return;

View File

@@ -36,7 +36,9 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
const $copyButton = $("<button>")
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
.attr("title", t("code_block.copy_title"))
.on("click", () => {
.on("click", (e) => {
e.stopPropagation();
if (!isShare) {
copyTextWithToast($codeBlock.text());
} else {

View File

@@ -1,5 +1,4 @@
import "jquery";
import "jquery-hotkeys";
import utils from "./services/utils.js";
import ko from "knockout";
import "./stylesheets/bootstrap.scss";

View File

@@ -29,6 +29,14 @@ async function formatCodeBlocks() {
await formatCodeBlocks($("#content"));
}
async function setupTextNote() {
formatCodeBlocks();
applyMath();
const setupMermaid = (await import("./share/mermaid.js")).default;
setupMermaid();
}
/**
* Fetch note with given ID from backend
*
@@ -47,8 +55,11 @@ async function fetchNote(noteId: string | null = null) {
document.addEventListener(
"DOMContentLoaded",
() => {
formatCodeBlocks();
applyMath();
const noteType = determineNoteType();
if (noteType === "text") {
setupTextNote();
}
const toggleMenuButton = document.getElementById("toggleMenuButton");
const layout = document.getElementById("layout");
@@ -60,6 +71,12 @@ document.addEventListener(
false
);
function determineNoteType() {
const bodyClass = document.body.className;
const match = bodyClass.match(/type-([^\s]+)/);
return match ? match[1] : null;
}
// workaround to prevent webpack from removing "fetchNote" as dead code:
// add fetchNote as property to the window object
Object.defineProperty(window, "fetchNote", {

View File

@@ -0,0 +1,17 @@
import mermaid from "mermaid";
export default function setupMermaid() {
for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) {
const parentPre = codeBlock.parentElement;
if (!parentPre) {
continue;
}
const mermaidDiv = document.createElement("div");
mermaidDiv.classList.add("mermaid");
mermaidDiv.innerHTML = codeBlock.innerHTML;
parentPre.replaceWith(mermaidDiv);
}
mermaid.init();
}

View File

@@ -81,8 +81,8 @@ body {
/* -- Overrides the default colors used by the ckeditor5-image package. --------------------- */
--ck-color-image-caption-background: var(--main-background-color);
--ck-color-image-caption-text: var(--main-text-color);
--ck-content-color-image-caption-background: var(--main-background-color);
--ck-content-color-image-caption-text: var(--main-text-color);
/* -- Overrides the default colors used by the ckeditor5-widget package. -------------------- */

View File

@@ -320,3 +320,8 @@ h6 {
page-break-after: avoid;
break-after: avoid;
}
figure.table {
/* Workaround for https://github.com/ckeditor/ckeditor5/issues/18903. Remove once official fix is released */
display: table !important;
}

View File

@@ -139,12 +139,6 @@ textarea,
color: var(--muted-text-color);
}
/* Restore default apperance */
input[type="number"],
input[type="checkbox"] {
appearance: auto !important;
}
/* Add a gap between consecutive radios / check boxes */
label.tn-radio + label.tn-radio,
label.tn-checkbox + label.tn-checkbox {
@@ -327,7 +321,8 @@ button kbd {
}
}
.dropdown-menu {
.dropdown-menu,
.tabulator-popup-container {
color: var(--menu-text-color) !important;
font-size: inherit;
background-color: var(--menu-background-color) !important;
@@ -342,7 +337,8 @@ button kbd {
break-after: avoid;
}
body.desktop .dropdown-menu {
body.desktop .dropdown-menu,
body.desktop .tabulator-popup-container {
border: 1px solid var(--dropdown-border-color);
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
animation: dropdown-menu-opening 100ms ease-in;
@@ -385,7 +381,8 @@ body.desktop .dropdown-menu {
}
.dropdown-menu a:hover:not(.disabled),
.dropdown-item:hover:not(.disabled, .dropdown-item-container) {
.dropdown-item:hover:not(.disabled, .dropdown-item-container),
.tabulator-menu-item:hover {
color: var(--hover-item-text-color) !important;
background-color: var(--hover-item-background-color) !important;
border-color: var(--hover-item-border-color) !important;
@@ -921,6 +918,13 @@ div[data-notify="container"] {
font-family: var(--monospace-font-family);
}
.ck-content {
--ck-content-font-family: var(--detail-font-family);
--ck-content-font-size: 1.1em;
--ck-content-font-color: var(--main-text-color);
--ck-content-line-height: var(--bs-body-line-height);
}
.ck-content .table table th {
background-color: var(--accented-background-color);
}
@@ -1207,12 +1211,14 @@ body.mobile .dropdown-submenu > .dropdown-menu {
}
#context-menu-container,
#context-menu-container .dropdown-menu {
padding: 3px 0 0;
#context-menu-container .dropdown-menu,
.tabulator-popup-container {
padding: 3px 0;
z-index: 2000;
}
#context-menu-container .dropdown-item {
#context-menu-container .dropdown-item,
.tabulator-menu .tabulator-menu-item {
padding: 0 7px 0 10px;
cursor: pointer;
user-select: none;
@@ -1774,6 +1780,54 @@ textarea {
padding: 1rem;
}
/* Command palette styling */
.jump-to-note-dialog .command-suggestion {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9em;
}
.jump-to-note-dialog .aa-suggestion .command-suggestion,
.jump-to-note-dialog .aa-suggestion .command-suggestion div {
padding: 0;
}
.jump-to-note-dialog .aa-cursor .command-suggestion,
.jump-to-note-dialog .aa-suggestion:hover .command-suggestion {
border-left-color: var(--link-color);
background-color: var(--hover-background-color);
}
.jump-to-note-dialog .command-icon {
color: var(--muted-text-color);
font-size: 1.125rem;
flex-shrink: 0;
margin-top: 0.125rem;
}
.jump-to-note-dialog .command-content {
flex-grow: 1;
min-width: 0;
}
.jump-to-note-dialog .command-name {
font-weight: bold;
}
.jump-to-note-dialog .command-description {
font-size: 0.8em;
line-height: 1.3;
opacity: 0.75;
}
.jump-to-note-dialog kbd.command-shortcut {
background-color: transparent;
color: inherit;
opacity: 0.75;
font-family: inherit !important;
}
.empty-table-placeholder {
text-align: center;
color: var(--muted-text-color);
@@ -1883,12 +1937,14 @@ body.zen .note-title-widget input {
/* Content renderer */
footer.file-footer {
footer.file-footer,
footer.webview-footer {
display: flex;
justify-content: center;
}
footer.file-footer button {
footer.file-footer button,
footer.webview-footer button {
margin: 5px;
}

View File

@@ -0,0 +1,199 @@
.tabulator {
--table-background-color: var(--main-background-color);
--col-header-background-color: var(--main-background-color);
--col-header-hover-background-color: var(--accented-background-color);
--col-header-text-color: var(--main-text-color);
--col-header-arrow-active-color: var(--main-text-color);
--col-header-arrow-inactive-color: var(--more-accented-background-color);
--col-header-separator-border: none;
--col-header-bottom-border: 2px solid var(--main-border-color);
--row-background-color: var(--main-background-color);
--row-alternate-background-color: var(--main-background-color);
--row-moving-background-color: var(--accented-background-color);
--row-text-color: var(--main-text-color);
--row-delimiter-color: var(--more-accented-background-color);
--cell-horiz-padding-size: 8px;
--cell-vert-padding-size: 8px;
--cell-editable-hover-outline-color: var(--main-border-color);
--cell-read-only-text-color: var(--muted-text-color);
--cell-editing-border-color: var(--main-border-color);
--cell-editing-border-width: 2px;
--cell-editing-background-color: var(--ck-color-selector-focused-cell-background);
--cell-editing-text-color: initial;
background: unset;
border: unset;
}
.tabulator .tabulator-tableholder .tabulator-table {
background: var(--table-background-color);
}
/* Column headers */
.tabulator div.tabulator-header {
border-bottom: var(--col-header-bottom-border);
background: var(--col-header-background-color);
color: var(--col-header-text-color);
}
.tabulator .tabulator-col-content {
padding: 8px 4px !important;
}
@media (hover: hover) and (pointer: fine) {
.tabulator .tabulator-header .tabulator-col.tabulator-sortable.tabulator-col-sorter-element:hover {
background-color: var(--col-header-hover-background-color);
}
}
.tabulator div.tabulator-header .tabulator-col.tabulator-moving {
border: none;
background: var(--col-header-hover-background-color);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--col-header-arrow-active-color);
border-top-color: var(--col-header-arrow-active-color);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--col-header-arrow-inactive-color);
}
.tabulator div.tabulator-header .tabulator-frozen.tabulator-frozen-left {
margin-left: var(--cell-editing-border-width);
}
.tabulator div.tabulator-header .tabulator-col,
.tabulator div.tabulator-header .tabulator-frozen.tabulator-frozen-left {
background: var(--col-header-background-color);
border-right: var(--col-header-separator-border);
}
/* Table body */
.tabulator-tableholder {
padding-top: 10px;
height: unset !important; /* Don't extend on the full height */
}
/* Rows */
.tabulator-row .tabulator-cell {
padding: var(--cell-vert-padding-size) var(--cell-horiz-padding-size);
}
.tabulator-row .tabulator-cell input {
padding-left: var(--cell-horiz-padding-size) !important;
padding-right: var(--cell-horiz-padding-size) !important;
}
.tabulator-row {
background: transparent;
border-top: none;
border-bottom: 1px solid var(--row-delimiter-color);
color: var(--row-text-color);
}
.tabulator-row.tabulator-row-odd {
background: var(--row-background-color);
}
.tabulator-row.tabulator-row-even {
background: var(--row-alternate-background-color);
}
.tabulator-row.tabulator-moving {
border-color: transparent;
background-color: var(--row-moving-background-color);
}
/* Cell */
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left {
margin-right: var(--cell-editing-border-width);
}
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left,
.tabulator-row .tabulator-cell {
border-right-color: transparent;
}
.tabulator-row .tabulator-cell:not(.tabulator-editable) {
color: var(--cell-read-only-text-color);
}
.tabulator:not(.tabulator-editing) .tabulator-row .tabulator-cell.tabulator-editable:hover {
outline: 2px solid var(--cell-editable-hover-outline-color);
outline-offset: -1px;
}
.tabulator-row .tabulator-cell.tabulator-editing {
border-color: transparent;
}
.tabulator-row:not(.tabulator-moving) .tabulator-cell.tabulator-editing {
outline: calc(var(--cell-editing-border-width) - 1px) solid var(--cell-editing-border-color);
border-color: var(--cell-editing-border-color);
background: var(--cell-editing-background-color);
}
.tabulator-row:not(.tabulator-moving) .tabulator-cell.tabulator-editing > * {
color: var(--cell-editing-text-color);
}
.tabulator .tree-collapse,
.tabulator .tree-expand {
color: var(--row-text-color);
}
/* Align items without children/expander to the ones with. */
.tabulator-cell[tabulator-field="title"] > span:first-child, /* 1st level */
.tabulator-cell[tabulator-field="title"] > div:first-child + span { /* sub-level */
padding-left: 21px;
}
/* Checkbox cells */
.tabulator .tabulator-cell:has(svg),
.tabulator .tabulator-cell:has(input[type="checkbox"]) {
padding-left: 8px;
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.tabulator .tabulator-cell input[type="checkbox"] {
margin: 0;
}
.tabulator .tabulator-footer {
color: var(--main-text-color);
}
/* Context menus */
.tabulator-popup-container {
min-width: 10em;
border-radius: var(--bs-border-radius);
}
.tabulator-menu .tabulator-menu-item {
border: 1px solid transparent;
color: var(--menu-text-color);
font-size: 16px;
}
/* Footer */
:root .tabulator .tabulator-footer {
border-top: unset;
padding: 10px 0;
}

View File

@@ -4,6 +4,7 @@
@import url(./pages.css);
@import url(./ribbon.css);
@import url(./notes/text.css);
@import url(./notes/collections/table.css);
@font-face {
font-family: "Inter";
@@ -183,7 +184,7 @@ html body .dropdown-item[disabled] {
/* Menu item icon */
.dropdown-item .bx {
transform: translateY(var(--menu-item-icon-vert-offset));
translate: 0 var(--menu-item-icon-vert-offset);
color: var(--menu-item-icon-color) !important;
font-size: 1.1em;
}
@@ -457,6 +458,11 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
padding: 1rem;
}
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
padding: 0;
}
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
padding: 1rem !important;
}

View File

@@ -128,10 +128,15 @@ div.tn-tool-dialog {
.jump-to-note-dialog .modal-header {
padding: unset !important;
padding-bottom: 26px !important;
}
.jump-to-note-dialog .modal-body {
padding: 26px 0 !important;
padding: 0 !important;
}
.jump-to-note-dialog .modal-footer {
padding-top: 26px;
}
/* Search box wrapper */

View File

@@ -0,0 +1,13 @@
:root .tabulator {
--col-header-hover-background-color: var(--hover-item-background-color);
--col-header-arrow-active-color: var(--active-item-text-color);
--col-header-arrow-inactive-color: var(--main-border-color);
--row-moving-background-color: var(--more-accented-background-color);
--cell-editable-hover-outline-color: var(--input-focus-outline-color);
--cell-editing-border-color: var(--input-focus-outline-color);
--cell-editing-background-color: var(--input-background-color);
--cell-editing-text-color: var(--input-text-color);
}

View File

@@ -1678,4 +1678,42 @@ div.find-replace-widget div.find-widget-found-wrapper > span {
#right-pane .highlights-list li:active {
background: transparent;
transition: none;
}
/** Canvas **/
.excalidraw {
--border-radius-lg: 6px;
}
.excalidraw .Island {
backdrop-filter: var(--dropdown-backdrop-filter);
}
.excalidraw .Island.App-toolbar {
--island-bg-color: var(--floating-button-background-color);
--shadow-island: 1px 1px 1px var(--floating-button-shadow-color);
}
.excalidraw .dropdown-menu {
border: unset !important;
box-shadow: unset !important;
background-color: transparent !important;
--island-bg-color: var(--menu-background-color);
--shadow-island: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
--default-border-color: var(--bs-dropdown-divider-bg);
--button-hover-bg: var(--hover-item-background-color);
}
.excalidraw .dropdown-menu .dropdown-menu-container {
border-radius: var(--dropdown-border-radius);
}
.excalidraw .dropdown-menu .dropdown-menu-container > div:not([class]):not(:last-child) {
margin-left: calc(var(--padding) * var(--space-factor) * -1) !important;
margin-right: calc(var(--padding) * var(--space-factor) * -1) !important;
}
.excalidraw .dropdown-menu:before {
content: unset !important;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -211,7 +211,7 @@
"okButton": "OK"
},
"jump_to_note": {
"search_placeholder": "search for note by its name",
"search_placeholder": "Search for note by its name or type > for commands...",
"close": "Close",
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
},
@@ -443,7 +443,8 @@
"other_notes_with_name": "Other notes with {{attributeType}} name \"{{attributeName}}\"",
"and_more": "... and {{count}} more.",
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>."
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Color"
},
"attribute_editor": {
"help_text_body1": "To add label, just type e.g. <code>#rock</code> or if you want to add also value then e.g. <code>#year = 2020</code>",
@@ -762,7 +763,8 @@
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar",
"table": "Table",
"geo-map": "Geo Map"
"geo-map": "Geo Map",
"board": "Board"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@@ -839,7 +841,8 @@
"unknown_label_type": "Unknown label type '{{type}}'",
"unknown_attribute_type": "Unknown attribute type '{{type}}'",
"add_new_attribute": "Add new attribute",
"remove_this_attribute": "Remove this attribute"
"remove_this_attribute": "Remove this attribute",
"remove_color": "Remove the color label"
},
"script_executor": {
"query": "Query",
@@ -1595,7 +1598,7 @@
"move-to": "Move to...",
"paste-into": "Paste into",
"paste-after": "Paste after",
"duplicate-subtree": "Duplicate subtree",
"duplicate": "Duplicate",
"export": "Export",
"import-into-note": "Import into note",
"apply-bulk-actions": "Apply bulk actions",
@@ -1615,7 +1618,7 @@
"relation-map": "Relation Map",
"note-map": "Note Map",
"render-note": "Render Note",
"book": "Book",
"book": "Collection",
"mermaid-diagram": "Mermaid Diagram",
"canvas": "Canvas",
"web-view": "Web View",
@@ -1944,10 +1947,63 @@
},
"table_view": {
"new-row": "New row",
"new-column": "New column"
"new-column": "New column",
"sort-column-by": "Sort by \"{{title}}\"",
"sort-column-ascending": "Ascending",
"sort-column-descending": "Descending",
"sort-column-clear": "Clear sorting",
"hide-column": "Hide column \"{{title}}\"",
"show-hide-columns": "Show/hide columns",
"row-insert-above": "Insert row above",
"row-insert-below": "Insert row below",
"row-insert-child": "Insert child note",
"add-column-to-the-left": "Add column to the left",
"add-column-to-the-right": "Add column to the right",
"edit-column": "Edit column",
"delete_column_confirmation": "Are you sure you want to delete this column? The corresponding attribute will be removed from all notes.",
"delete-column": "Delete column",
"new-column-label": "Label",
"new-column-relation": "Relation"
},
"book_properties_config": {
"hide-weekends": "Hide weekends",
"display-week-numbers": "Display week numbers"
"display-week-numbers": "Display week numbers",
"map-style": "Map style:",
"max-nesting-depth": "Max nesting depth:",
"raster": "Raster",
"vector_light": "Vector (Light)",
"vector_dark": "Vector (Dark)",
"show-scale": "Show scale"
},
"table_context_menu": {
"delete_row": "Delete row"
},
"board_view": {
"delete-note": "Delete Note",
"move-to": "Move to",
"insert-above": "Insert above",
"insert-below": "Insert below",
"delete-column": "Delete column",
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
"new-item": "New item",
"add-column": "Add Column"
},
"command_palette": {
"tree-action-name": "Tree: {{name}}",
"export_note_title": "Export Note",
"export_note_description": "Export current note",
"show_attachments_title": "Show Attachments",
"show_attachments_description": "View note attachments",
"search_notes_title": "Search Notes",
"search_notes_description": "Open advanced search",
"search_subtree_title": "Search in Subtree",
"search_subtree_description": "Search within current subtree",
"search_history_title": "Show Search History",
"search_history_description": "View previous searches",
"configure_launch_bar_title": "Configure Launch Bar",
"configure_launch_bar_description": "Open the launch bar configuration, to add or remove items."
},
"content_renderer": {
"open_externally": "Open externally"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,7 @@
{
"revisions": {
"delete_button": ""
},
"code_block": {
"theme_none": "Sem destaque de sintaxe",
"theme_group_light": "Temas claros",
"theme_group_dark": "Temas escuros"
}
"code_block": {
"theme_none": "Sem destaque de sintaxe",
"theme_group_light": "Temas claros",
"theme_group_dark": "Temas escuros"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
{
"about": {
"title": "O Trilium Belеškama",
"close": "Zatvori",
"homepage": "Početna stranica:",
"app_version": "Verzija aplikacije:",
"db_version": "Verzija baze podataka:",
"sync_version": "Verzija sinhronizacije:",
"build_date": "Datum izgradnje:",
"build_revision": "Revizija izgradnje:",
"data_directory": "Direktorijum sa podacima:"
},
"toast": {
"critical-error": {
"title": "Kritična greška",
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
},
"widget-error": {
"title": "Pokretanje vidžeta nije uspelo",
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
},
"bundle-error": {
"title": "Pokretanje prilagođene skripte neuspešno",
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
}
},
"add_link": {
"add_link": "Dodaj link",
"help_on_links": "Pomoć na linkovima",
"close": "Zatvori",
"note": "Beleška",
"search_note": "potražite belešku po njenom imenu",
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
"link_title": "Naziv linka",
"button_add_link": "Dodaj link <kbd>enter</kbd>"
},
"branch_prefix": {
"edit_branch_prefix": "Izmeni prefiks grane",
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
"close": "Zatvori",
"prefix": "Prefiks: ",
"save": "Sačuvaj",
"branch_prefix_saved": "Prefiks grane je sačuvan."
},
"bulk_actions": {
"bulk_actions": "Grupne akcije",
"close": "Zatvori",
"affected_notes": "Pogođene beleške",
"include_descendants": "Obuhvati potomke izabranih beleški",
"available_actions": "Dostupne akcije",
"chosen_actions": "Izabrane akcije",
"execute_bulk_actions": "Izvrši grupne akcije",
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
"labels": "Oznake",
"relations": "Odnosi",
"notes": "Beleške",
"other": "Ostalo"
},
"clone_to": {
"clone_notes_to": "Klonirajte beleške u...",
"close": "Zatvori",
"help_on_links": "Pomoć na linkovima",
"notes_to_clone": "Beleške za kloniranje",
"target_parent_note": "Ciljna nadređena beleška",
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
"prefix_optional": "Prefiks (opciono)",
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
"no_path_to_clone_to": "Nema putanje za kloniranje.",
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
},
"confirm": {
"confirmation": "Potvrda",
"close": "Zatvori",
"cancel": "Otkaži",
"ok": "U redu",
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
},
"export": {
"export_note_title": "Izvezi belešku",
"close": "Zatvori",
"export_type_subtree": "Ova beleška i svi njeni potomci",
"format_html": "HTML - preporučuje se jer čuva formatiranje",
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
"opml_version_1": "OPML v1.0 - samo običan tekst",
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
"export_type_single": "Samo ovu belešku bez njenih potomaka",
"export": "Izvoz",
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
"export_status": "Status izvoza",
"export_in_progress": "Izvoz u toku: {{progressCount}}",
"export_finished_successfully": "Izvoz je uspešno završen.",
"format_pdf": "PDF - za namene štampanja ili deljenja."
},
"help": {
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
"close": "Zatvori",
"noteNavigation": "Navigacija beleški",
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
"notSet": "nije podešeno",
"goBackForwards": "idi u nazad/napred kroz istoriju",
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
"scrollToActiveNote": "skroluj do aktivne beleške",
"jumpToParentNote": "<kbd>Backspace</kbd> - idi do nadređene beleške",
"collapseWholeTree": "sakupi celo drvo beleški",
"collapseSubTree": "sakupi pod-drvo",
"tabShortcuts": "Prečice na karticama",
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (ili <kbd>middle mouse click</kbd>) na link beleške otvara belešku u novoj kartici",
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (ili <kbd>Shift+middle mouse click</kbd>) na link beleške otvara i aktivira belešku u novoj kartici",
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
"openEmptyTab": "otvori praznu karticu",
"closeActiveTab": "zatvori aktivnu karticu",
"activateNextTab": "aktiviraj narednu karticu",
"activatePreviousTab": "aktiviraj prethodnu karticu",
"creatingNotes": "Pravljenje beleški",
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
"movingCloningNotes": "Premeštanje / kloniranje beleški",
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
"selectNote": "<kbd>Shift+click</kbd> - izaberi belešku",
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
"deleteNotes": "obriši belešku / podstablo",
"editingNotes": "Izmena beleški",
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
"createEditLink": "<kbd>Ctrl+K</kbd> - napravi / izmeni spoljašnji link",
"createInternalLink": "napravi unutrašnji link",
"followLink": "prati link ispod kursora",
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
"troubleshooting": "Rešavanje problema",
"reloadFrontend": "ponovo učitaj Trilium frontend",
"showDevTools": "prikaži alate za programere",
"showSQLConsole": "prikaži SQL konzolu",
"other": "Ostalo",
"quickSearch": "fokus na unos za brzu pretragu",
"inPageSearch": "pretraga unutar stranice"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,11 @@ declare module "*.png" {
export default path;
}
declare module "*.json" {
var content: any;
export default content;
}
declare module "*?url" {
var path: string;
export default path;

View File

@@ -97,16 +97,6 @@ declare global {
setNote(noteId: string);
}
interface JQueryStatic {
hotkeys: {
options: {
filterInputAcceptingElements: boolean;
filterContentEditable: boolean;
filterTextInputs: boolean;
}
}
}
var logError: (message: string, e?: Error | string) => void;
var logInfo: (message: string) => void;
var glob: CustomGlobals;

View File

@@ -78,7 +78,7 @@ const TPL = /*html*/`
}
</style>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<h5 class="attr-detail-title">${t("attribute_detail.attr_detail_title")}</h5>
<span class="bx bx-x close-attr-detail-button tn-tool-button" title="${t("attribute_detail.close_button_title")}"></span>
@@ -142,6 +142,7 @@ const TPL = /*html*/`
<option value="datetime">${t("attribute_detail.date_time")}</option>
<option value="time">${t("attribute_detail.time")}</option>
<option value="url">${t("attribute_detail.url")}</option>
<option value="color">${t("attribute_detail.color_type")}</option>
</select>
</td>
</tr>
@@ -295,6 +296,8 @@ interface AttributeDetailOpts {
x: number;
y: number;
focus?: "name";
parent?: HTMLElement;
hideMultiplicity?: boolean;
}
interface SearchRelatedResponse {
@@ -477,7 +480,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
});
}
async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus }: AttributeDetailOpts) {
async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus, hideMultiplicity }: AttributeDetailOpts) {
if (!attribute) {
this.hide();
@@ -528,7 +531,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.$rowPromotedAlias.toggle(!!definition.isPromoted);
this.$inputPromotedAlias.val(definition.promotedAlias || "").attr("disabled", disabledFn);
this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || ""));
this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || "") && !hideMultiplicity);
this.$inputMultiplicity.val(definition.multiplicity || "").attr("disabled", disabledFn);
this.$rowLabelType.toggle(this.attrType === "label-definition");
@@ -560,19 +563,22 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.toggleInt(true);
const offset = this.parent?.$widget.offset() || { top: 0, left: 0 };
const offset = this.parent?.$widget?.offset() || { top: 0, left: 0 };
const detPosition = this.getDetailPosition(x, offset);
const outerHeight = this.$widget.outerHeight();
const height = $(window).height();
if (detPosition && outerHeight && height) {
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
if (!detPosition || !outerHeight || !height) {
console.warn("Can't position popup, is it attached?");
return;
}
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
if (focus === "name") {
this.$inputName.trigger("focus").trigger("select");
}

View File

@@ -4,7 +4,7 @@ import noteAutocompleteService, { type Suggestion } from "../../services/note_au
import server from "../../services/server.js";
import contextMenuService from "../../menus/context_menu.js";
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
import { AttributeEditor, type EditorConfig, type Element, type MentionFeed, type Node, type Position } from "@triliumnext/ckeditor5";
import { AttributeEditor, type EditorConfig, type ModelElement, type MentionFeed, type ModelNode, type ModelPosition } from "@triliumnext/ckeditor5";
import froca from "../../services/froca.js";
import attributeRenderer from "../../services/attribute_renderer.js";
import noteCreateService from "../../services/note_create.js";
@@ -417,16 +417,16 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
this.$editor.tooltip("show");
}
getClickIndex(pos: Position) {
getClickIndex(pos: ModelPosition) {
let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0);
let curNode: Node | Text | Element | null = pos.textNode;
let curNode: ModelNode | Text | ModelElement | null = pos.textNode;
while (curNode?.previousSibling) {
curNode = curNode.previousSibling;
if ((curNode as Element).name === "reference") {
clickIndex += (curNode.getAttribute("notePath") as string).length + 1;
if ((curNode as ModelElement).name === "reference") {
clickIndex += (curNode.getAttribute("href") as string).length + 1;
} else if ("data" in curNode) {
clickIndex += (curNode.data as string).length;
}

View File

@@ -1,9 +1,10 @@
import { ActionKeyboardShortcut } from "@triliumnext/commons";
import type { CommandNames } from "../../components/app_context.js";
import keyboardActionsService, { type Action } from "../../services/keyboard_actions.js";
import keyboardActionsService from "../../services/keyboard_actions.js";
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
import type { ButtonNoteIdProvider } from "./button_from_note.js";
let actions: Action[];
let actions: ActionKeyboardShortcut[];
keyboardActionsService.getActions().then((as) => (actions = as));
@@ -49,7 +50,7 @@ export default class CommandButtonWidget extends AbstractButtonWidget<CommandBut
const action = actions.find((act) => act.actionName === this._command);
if (action && action.effectiveShortcuts.length > 0) {
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title;

View File

@@ -186,7 +186,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));

View File

@@ -268,7 +268,7 @@ export default class RibbonContainer extends NoteContextAwareWidget {
const action = actions.find((act) => act.actionName === toggleCommandName);
const title = $(this).attr("data-title");
if (action && action.effectiveShortcuts.length > 0) {
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title ?? "";

View File

@@ -6,6 +6,7 @@ import BasicWidget from "../basic_widget.js";
import shortcutService from "../../services/shortcuts.js";
import { Modal } from "bootstrap";
import { openDialog } from "../../services/dialog.js";
import commandRegistry from "../../services/command_registry.js";
const TPL = /*html*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
@@ -34,7 +35,8 @@ export default class JumpToNoteDialog extends BasicWidget {
private modal!: bootstrap.Modal;
private $autoComplete!: JQuery<HTMLElement>;
private $results!: JQuery<HTMLElement>;
private $showInFullTextButton!: JQuery<HTMLElement>;
private $modalFooter!: JQuery<HTMLElement>;
private isCommandMode: boolean = false;
constructor() {
super();
@@ -48,13 +50,44 @@ export default class JumpToNoteDialog extends BasicWidget {
this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete");
this.$results = this.$widget.find(".jump-to-note-results");
this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
this.$showInFullTextButton.on("click", (e) => this.showInFullText(e));
this.$modalFooter = this.$widget.find(".modal-footer");
this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e));
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
// Monitor input changes to detect command mode switches
this.$autoComplete.on("input", () => {
this.updateCommandModeState();
});
}
private updateCommandModeState() {
const currentValue = String(this.$autoComplete.val() || "");
const newCommandMode = currentValue.startsWith(">");
if (newCommandMode !== this.isCommandMode) {
this.isCommandMode = newCommandMode;
this.updateButtonVisibility();
}
}
private updateButtonVisibility() {
if (this.isCommandMode) {
this.$modalFooter.hide();
} else {
this.$modalFooter.show();
}
}
async jumpToNoteEvent() {
await this.openDialog();
}
async commandPaletteEvent() {
await this.openDialog(true);
}
private async openDialog(commandMode = false) {
const dialogPromise = openDialog(this.$widget);
if (utils.isMobile()) {
dialogPromise.then(($dialog) => {
@@ -81,50 +114,89 @@ export default class JumpToNoteDialog extends BasicWidget {
}
// first open dialog, then refresh since refresh is doing focus which should be visible
this.refresh();
this.refresh(commandMode);
this.lastOpenedTs = Date.now();
}
async refresh() {
async refresh(commandMode = false) {
noteAutocompleteService
.initNoteAutocomplete(this.$autoComplete, {
allowCreatingNotes: true,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true,
container: this.$results[0]
container: this.$results[0],
isCommandPalette: true
})
// clear any event listener added in previous invocation of this function
.off("autocomplete:noteselected")
.off("autocomplete:commandselected")
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
if (!suggestion.notePath) {
return false;
}
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
})
.on("autocomplete:commandselected", async (event, suggestion, dataset) => {
if (!suggestion.commandId) {
return false;
}
this.modal.hide();
await commandRegistry.executeCommand(suggestion.commandId);
});
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
// so we'll keep the content.
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
noteAutocompleteService.showRecentNotes(this.$autoComplete);
if (commandMode) {
// Start in command mode - manually trigger command search
this.$autoComplete.autocomplete("val", ">");
this.isCommandMode = true;
this.updateButtonVisibility();
// Manually populate with all commands immediately
noteAutocompleteService.showAllCommands(this.$autoComplete);
this.$autoComplete.trigger("focus");
} else {
this.$autoComplete
// hack, the actual search value is stored in <pre> element next to the search input
// this is important because the search input value is replaced with the suggestion note's title
.autocomplete("val", this.$autoComplete.next().text())
.trigger("focus")
.trigger("select");
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
// so we'll keep the content.
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
this.isCommandMode = false;
this.updateButtonVisibility();
noteAutocompleteService.showRecentNotes(this.$autoComplete);
} else {
this.$autoComplete
// hack, the actual search value is stored in <pre> element next to the search input
// this is important because the search input value is replaced with the suggestion note's title
.autocomplete("val", this.$autoComplete.next().text())
.trigger("focus")
.trigger("select");
// Update command mode state based on the restored value
this.updateCommandModeState();
// If we restored a command mode value, manually trigger command display
if (this.isCommandMode) {
// Clear the value first, then set it to ">" to trigger a proper change
this.$autoComplete.autocomplete("val", "");
noteAutocompleteService.showAllCommands(this.$autoComplete);
}
}
}
}
showInFullText(e: JQuery.TriggeredEvent) {
showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
e.preventDefault();
e.stopPropagation();
// Don't perform full text search in command mode
if (this.isCommandMode) {
return;
}
const searchString = String(this.$autoComplete.val());
this.triggerCommand("searchNotes", { searchString });

View File

@@ -49,7 +49,7 @@ const TPL = /*html*/`\
left: 0;
right: 0;
background: var(--modal-background-color);
z-index: 1000;
z-index: 998;
}
.modal.popup-editor-dialog .note-detail-file {
@@ -106,7 +106,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
focus: false
});
await this.noteContext.setNote(noteIdOrPath);
await this.noteContext.setNote(noteIdOrPath, {
viewScope: {
readOnlyTemporarilyDisabled: true
}
});
const activeEl = document.activeElement;
if (activeEl && "blur" in activeEl) {

View File

@@ -88,7 +88,9 @@ export default class SortChildNotesDialog extends BasicWidget {
this.$widget = $(TPL);
this.$form = this.$widget.find(".sort-child-notes-form");
this.$form.on("submit", async () => {
this.$form.on("submit", async (e) => {
e.preventDefault();
const sortBy = this.$form.find("input[name='sort-by']:checked").val();
const sortDirection = this.$form.find("input[name='sort-direction']:checked").val();
const foldersFirst = this.$form.find("input[name='sort-folders-first']").is(":checked");

View File

@@ -97,6 +97,7 @@ const TPL = /*html*/`
</div>
</div>`;
const SUPPORTED_NOTE_TYPES = ["text", "code", "render", "mindMap", "doc"];
export default class FindWidget extends NoteContextAwareWidget {
private searchTerm: string | null;
@@ -188,7 +189,7 @@ export default class FindWidget extends NoteContextAwareWidget {
return;
}
if (!["text", "code", "render", "mindMap"].includes(this.note?.type ?? "")) {
if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
return;
}
@@ -251,6 +252,7 @@ export default class FindWidget extends NoteContextAwareWidget {
const readOnly = await this.noteContext?.isReadOnly();
return readOnly ? this.htmlHandler : this.textHandler;
case "mindMap":
case "doc":
return this.htmlHandler;
default:
console.warn("FindWidget: Unsupported note type for find widget", this.note?.type);
@@ -354,7 +356,7 @@ export default class FindWidget extends NoteContextAwareWidget {
}
isEnabled() {
return super.isEnabled() && ["text", "code", "render", "mindMap"].includes(this.note?.type ?? "");
return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@@ -35,7 +35,8 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
grid: "8QqnMzx393bx",
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc"
geoMap: "81SGnPGMk7Xc",
board: "CtBQqbwXDx1w"
};
export default class ContextualHelpButton extends NoteContextAwareWidget {

View File

@@ -3,8 +3,6 @@ import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js";
import type ViewMode from "./view_widgets/view_mode.js";
import AttributeDetailWidget from "./attribute_widgets/attribute_detail.js";
import { Attribute } from "../services/attribute_parser.js";
const TPL = /*html*/`
<div class="note-list-widget">
@@ -39,7 +37,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
private noteIdRefreshed?: string;
private shownNoteId?: string | null;
private viewMode?: ViewMode<any> | null;
private attributeDetailWidget: AttributeDetailWidget;
private displayOnlyCollections: boolean;
/**
@@ -47,9 +44,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
*/
constructor(displayOnlyCollections: boolean) {
super();
this.attributeDetailWidget = new AttributeDetailWidget()
.contentSized()
.setParent(this);
this.displayOnlyCollections = displayOnlyCollections;
}
@@ -72,7 +67,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
this.$widget = $(TPL);
this.contentSized();
this.$content = this.$widget.find(".note-list-widget-content");
this.$widget.append(this.attributeDetailWidget.render());
const observer = new IntersectionObserver(
(entries) => {
@@ -91,23 +85,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
setTimeout(() => observer.observe(this.$widget[0]), 10);
}
addNoteListItemEvent() {
const attr: Attribute = {
type: "label",
name: "label:myLabel",
value: "promoted,single,text"
};
this.attributeDetailWidget!.showAttributeDetail({
attribute: attr,
allAttributes: [ attr ],
isOwned: true,
x: 100,
y: 200,
focus: "name"
});
}
checkRenderStatus() {
// console.log("this.isIntersecting", this.isIntersecting);
// console.log(`${this.noteIdRefreshed} === ${this.noteId}`, this.noteIdRefreshed === this.noteId);
@@ -123,8 +100,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
parentNotePath: this.notePath,
noteIds: note.getChildNoteIds()
parentNotePath: this.notePath
});
this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
await noteListRenderer.renderList();
@@ -169,12 +145,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
this.refresh();
this.checkRenderStatus();
}
// Inform the view mode of changes and refresh if needed.
if (this.viewMode && this.viewMode.onEntitiesReloaded(e)) {
this.refresh();
this.checkRenderStatus();
}
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {

View File

@@ -727,9 +727,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
for (const key in hotKeys) {
const handler = hotKeys[key];
$(this.tree.$container).on("keydown", null, key, (evt) => {
shortcutService.bindElShortcut($(this.tree.$container), key, () => {
const node = this.tree.getActiveNode();
return handler(node, evt);
return handler(node, {} as JQuery.KeyDownEvent);
// return false from the handler will stop default handling.
});
}
@@ -1552,7 +1552,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
const hotKeyMap: Record<string, (node: Fancytree.FancytreeNode, e: JQuery.KeyDownEvent) => boolean> = {};
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts) {
for (const shortcut of action.effectiveShortcuts ?? []) {
hotKeyMap[shortcutService.normalizeShortcut(shortcut)] = (node) => {
const notePath = treeService.getNotePath(node);

View File

@@ -5,6 +5,16 @@ import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
import attributes from "../../services/attributes.js";
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: t("book_properties.grid"),
list: t("book_properties.list"),
calendar: t("book_properties.calendar"),
table: t("book_properties.table"),
geoMap: t("book_properties.geo-map"),
board: t("book_properties.board")
};
const TPL = /*html*/`
<div class="book-properties-widget">
@@ -23,24 +33,37 @@ const TPL = /*html*/`
align-items: center;
}
.book-properties-container > * {
.book-properties-container > div {
margin-right: 15px;
}
.book-properties-container > .type-number > label {
display: flex;
align-items: baseline;
}
.book-properties-container input[type="checkbox"] {
margin-right: 5px;
}
.book-properties-container label {
display: flex;
justify-content: center;
align-items: center;
text-overflow: clip;
white-space: nowrap;
}
</style>
<div style="display: flex; align-items: baseline">
<span style="white-space: nowrap">${t("book_properties.view_type")}:&nbsp; &nbsp;</span>
<select class="view-type-select form-select form-select-sm">
<option value="grid">${t("book_properties.grid")}</option>
<option value="list">${t("book_properties.list")}</option>
<option value="calendar">${t("book_properties.calendar")}</option>
<option value="table">${t("book_properties.table")}</option>
<option value="geoMap">${t("book_properties.geo-map")}</option>
${Object.entries(VIEW_TYPE_MAPPINGS)
.filter(([type]) => type !== "raster")
.map(([type, label]) => `
<option value="${type}">${label}</option>
`).join("")}
</select>
</div>
@@ -110,7 +133,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
return;
}
if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
if (!VIEW_TYPE_MAPPINGS.hasOwnProperty(type)) {
throw new Error(t("book_properties.invalid_view_type", { type }));
}
@@ -127,6 +150,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
renderBookProperty(property: BookProperty) {
const $container = $("<div>");
$container.addClass(`type-${property.type}`);
const note = this.note;
if (!note) {
return $container;
@@ -168,6 +192,56 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
});
$container.append($button);
break;
case "number":
const $numberInput = $("<input>", {
type: "number",
class: "form-control form-control-sm",
value: note.getLabelValue(property.bindToLabel) || "",
width: property.width ?? 100,
min: property.min ?? 0
});
$numberInput.on("change", () => {
const value = $numberInput.val();
if (value === "") {
attributes.removeOwnedLabelByName(note, property.bindToLabel);
} else {
attributes.setLabel(note.noteId, property.bindToLabel, String(value));
}
});
$container.append($("<label>")
.text(property.label)
.append("&nbsp;".repeat(2))
.append($numberInput));
break;
case "combobox":
const $select = $("<select>", {
class: "form-select form-select-sm"
});
const actualValue = note.getLabelValue(property.bindToLabel) ?? property.defaultValue ?? "";
for (const option of property.options) {
if ("items" in option) {
const $optGroup = $("<optgroup>", { label: option.name });
for (const item of option.items) {
buildComboBoxItem(item, actualValue).appendTo($optGroup);
}
$optGroup.appendTo($select);
} else {
buildComboBoxItem(option, actualValue).appendTo($select);
}
}
$select.on("change", () => {
const value = $select.val();
if (value === null || value === "") {
attributes.removeOwnedLabelByName(note, property.bindToLabel);
} else {
attributes.setLabel(note.noteId, property.bindToLabel, String(value));
}
});
$container.append($("<label>")
.text(property.label)
.append("&nbsp;".repeat(2))
.append($select));
break;
}
return $container;
@@ -175,3 +249,14 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
}
function buildComboBoxItem({ value, label }: { value: string, label: string }, actualValue: string) {
const $option = $("<option>", {
value,
text: label
});
if (actualValue === value) {
$option.prop("selected", true);
}
return $option;
}

View File

@@ -3,8 +3,7 @@ import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { ViewTypeOptions } from "../../services/note_list_renderer"
import NoteContextAwareWidget from "../note_context_aware_widget";
export type BookProperty = CheckBoxProperty | ButtonProperty;
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../view_widgets/geo_view/map_layer";
interface BookConfig {
properties: BookProperty[];
@@ -24,6 +23,37 @@ interface ButtonProperty {
onClick: (context: BookContext) => void;
}
interface NumberProperty {
type: "number",
label: string;
bindToLabel: string;
width?: number;
min?: number;
}
interface ComboBoxItem {
value: string;
label: string;
}
interface ComboBoxGroup {
name: string;
items: ComboBoxItem[];
}
interface ComboBoxProperty {
type: "combobox",
label: string;
bindToLabel: string;
/**
* The default value is used when the label is not set.
*/
defaultValue?: string;
options: (ComboBoxItem | ComboBoxGroup)[];
}
export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty | ComboBoxProperty;
interface BookContext {
note: FNote;
triggerCommand: NoteContextAwareWidget["triggerCommand"];
@@ -82,9 +112,58 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
]
},
geoMap: {
properties: []
properties: [
{
label: t("book_properties_config.map-style"),
type: "combobox",
bindToLabel: "map:style",
defaultValue: DEFAULT_MAP_LAYER_NAME,
options: [
{
name: t("book_properties_config.raster"),
items: Object.entries(MAP_LAYERS)
.filter(([_, layer]) => layer.type === "raster")
.map(buildMapLayer)
},
{
name: t("book_properties_config.vector_light"),
items: Object.entries(MAP_LAYERS)
.filter(([_, layer]) => layer.type === "vector" && !layer.isDarkTheme)
.map(buildMapLayer)
},
{
name: t("book_properties_config.vector_dark"),
items: Object.entries(MAP_LAYERS)
.filter(([_, layer]) => layer.type === "vector" && layer.isDarkTheme)
.map(buildMapLayer)
}
]
},
{
label: t("book_properties_config.show-scale"),
type: "checkbox",
bindToLabel: "map:scale"
}
]
},
table: {
properties: [
{
label: t("book_properties_config.max-nesting-depth"),
type: "number",
bindToLabel: "maxNestingDepth",
width: 65
}
]
},
board: {
properties: []
}
};
function buildMapLayer([ id, layer ]: [ string, MapLayer ]): ComboBoxItem {
return {
value: id,
label: layer.name
};
}

View File

@@ -53,12 +53,56 @@ const TPL = /*html*/`
word-break:keep-all;
white-space: nowrap;
}
.promoted-attribute-cell input[type="checkbox"] {
width: 22px !important;
flex-grow: 0;
width: unset;
}
/* Restore default apperance */
.promoted-attribute-cell input[type="number"],
.promoted-attribute-cell input[type="checkbox"] {
appearance: auto;
}
.promoted-attribute-cell input[type="color"] {
width: 24px;
height: 24px;
margin-top: 2px;
appearance: none;
padding: 0;
border: 0;
outline: none;
border-radius: 25% !important;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 25%;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
position: relative;
opacity: 0.5;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
content: "";
position: absolute;
top: 10px;
left: 0px;
right: 0;
height: 2px;
background: rgba(0, 0, 0, 0.5);
transform: rotate(45deg);
pointer-events: none;
}
</style>
<div class="promoted-attributes-container"></div>
@@ -258,6 +302,35 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
.on("click", () => window.open($input.val() as string, "_blank"));
$input.after($openButton);
} else if (definition.labelType === "color") {
const defaultColor = "#ffffff";
$input.prop("type", "hidden");
$input.val(valueAttr.value ?? "");
// We insert a separate input since the color input does not support empty value.
// This is a workaround to allow clearing the color input.
const $colorInput = $("<input>")
.prop("type", "color")
.prop("value", valueAttr.value || defaultColor)
.addClass("form-control promoted-attribute-input")
.on("change", e => setValue((e.target as HTMLInputElement).value, e));
$input.after($colorInput);
const $clearButton = $("<span>")
.addClass("input-group-text bx bxs-tag-x")
.prop("title", t("promoted_attributes.remove_color"))
.on("click", e => setValue("", e));
const setValue = (color: string, event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => {
$input.val(color);
if (!color) {
$colorInput.val(defaultColor);
}
event.target = $input[0]; // Set the event target to the main input
this.promotedAttributeChanged(event);
};
$colorInput.after($clearButton);
} else {
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
}

View File

@@ -68,7 +68,6 @@ export default class SearchResultWidget extends NoteContextAwareWidget {
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
noteIds: note.getChildNoteIds(),
showNotePath: true
});
await noteListRenderer.renderList();

View File

@@ -8,6 +8,7 @@ import appContext, { type CommandNames, type CommandListenerData, type EventData
import froca from "../services/froca.js";
import attributeService from "../services/attributes.js";
import type NoteContext from "../components/note_context.js";
import { setupHorizontalScrollViaWheel } from "./widget_utils.js";
const isDesktop = utils.isDesktop();
@@ -386,15 +387,7 @@ export default class TabRowWidget extends BasicWidget {
};
setupScrollEvents() {
this.$tabScrollingContainer.on('wheel', (event) => {
const wheelEvent = event.originalEvent as WheelEvent;
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
});
setupHorizontalScrollViaWheel(this.$tabScrollingContainer);
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-210));
this.$scrollButtonRight[0].addEventListener('click', () => this.scrollTabContainer(210));

View File

@@ -130,7 +130,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
constructor() {
super();
this.editorTypeWidget = new EditableCodeTypeWidget();
this.editorTypeWidget = new EditableCodeTypeWidget(true);
this.editorTypeWidget.updateBackgroundColor = () => {};
this.editorTypeWidget.isEnabled = () => true;
@@ -146,6 +147,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
doRender(): void {
this.$widget = $(TPL);
this.spacedUpdate.setUpdateInterval(750);
// Preview pane
this.$previewCol = this.$widget.find(".note-detail-split-preview-col");
this.$preview = this.$widget.find(".note-detail-split-preview");

View File

@@ -45,12 +45,11 @@ export default class BookTypeWidget extends TypeWidget {
}
switch (this.note?.getAttributeValue("label", "viewType")) {
case "calendar":
case "table":
case "geoMap":
return false;
default:
case "list":
case "grid":
return true;
default:
return false;
}
}

View File

@@ -166,7 +166,6 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
onChange: () => this.onChangeHandler(),
viewModeEnabled: options.is("databaseReadonly"),
zenModeEnabled: false,
gridModeEnabled: false,
isCollaborating: false,
detectScroll: false,
handleKeyboardGlobally: false,

View File

@@ -153,7 +153,8 @@ export default class Canvas {
appState: {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom
zoom: appState.zoom,
gridModeEnabled: appState.gridModeEnabled
}
};

View File

@@ -59,7 +59,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) {
const templateNoteIds = new Set(templateCache.keys());
const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds);
await froca.getNotes(affectedNoteIds);
await froca.getNotes(affectedNoteIds, true);
let fullReloadNeeded = false;
for (const affectedTemplateNoteId of affectedTemplateNoteIds) {

View File

@@ -28,6 +28,16 @@ const TPL = /*html*/`
export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
private debounceUpdate: boolean;
/**
* @param debounceUpdate if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
*/
constructor(debounceUpdate: boolean = false) {
super();
this.debounceUpdate = debounceUpdate;
}
static getType() {
return "editableCode";
}
@@ -46,7 +56,13 @@ export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
return {
placeholder: t("editable_code.placeholder"),
vimKeybindings: options.is("vimKeymapEnabled"),
onContentChanged: () => this.spacedUpdate.scheduleUpdate(),
onContentChanged: () => {
if (this.debounceUpdate) {
this.spacedUpdate.resetUpdateTimer();
}
this.spacedUpdate.scheduleUpdate();
},
tabIndex: 300
}
}

View File

@@ -265,7 +265,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
focus() {
this.$editor.trigger("focus");
const editor = this.watchdog.editor;
if (editor) {
editor.editing.view.focus();
} else {
this.$editor.trigger("focus");
}
}
scrollToEnd() {

View File

@@ -3,7 +3,7 @@ import utils from "../../../services/utils.js";
import dialogService from "../../../services/dialog.js";
import OptionsWidget from "./options_widget.js";
import { t } from "../../../services/i18n.js";
import type { OptionNames, KeyboardShortcut } from "@triliumnext/commons";
import type { OptionNames, KeyboardShortcut, KeyboardShortcutWithRequiredActionName } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="options-section shortcuts-options-section tn-no-card">
@@ -75,10 +75,10 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
for (const action of actions) {
const $tr = $("<tr>");
if (action.separator) {
if ("separator" in action) {
$tr.append($('<td class="separator" colspan="4">').attr("style", "background-color: var(--accented-background-color); font-weight: bold;").text(action.separator));
} else if (action.defaultShortcuts && action.actionName) {
$tr.append($("<td>").text(action.actionName))
$tr.append($("<td>").text(action.friendlyName))
.append(
$("<td>").append(
$(`<input type="text" class="form-control">`)
@@ -145,9 +145,9 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
return;
}
const action = globActions.find((act) => act.actionName === actionName);
const action = globActions.find((act) => "actionName" in act && act.actionName === actionName) as KeyboardShortcutWithRequiredActionName;
if (!action || !action.actionName) {
if (!action) {
this.$widget.find(el).hide();
return;
}
@@ -157,6 +157,7 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
.toggle(
!!(
action.actionName.toLowerCase().includes(filter) ||
(action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) ||
(action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
(action.effectiveShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
(action.description && action.description.toLowerCase().includes(filter))

View File

@@ -52,7 +52,8 @@ export default class DateTimeFormatOptions extends OptionsWidget {
}
async optionsLoaded(options: OptionMap) {
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
const action = await keyboardActionsService.getAction("insertDateTimeToText");
const shortcutKey = (action.effectiveShortcuts ?? []).join(", ");
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
"title": shortcutKey,
"showTooltip": false

View File

@@ -71,6 +71,17 @@ export default abstract class TypeWidget extends NoteContextAwareWidget {
}
}
activeNoteChangedEvent() {
if (!this.isActiveNoteContext()) {
return;
}
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
if (!document.activeElement?.classList.contains("fancytree-title")) {
this.focus();
}
}
/**
* {@inheritdoc}
*

View File

@@ -0,0 +1,180 @@
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
import { executeBulkActions } from "../../../services/bulk_action";
import note_create from "../../../services/note_create";
import ViewModeStorage from "../view_mode_storage";
import { BoardData } from "./config";
import { ColumnMap, getBoardData } from "./data";
export default class BoardApi {
private constructor(
private _columns: string[],
private _parentNoteId: string,
private viewStorage: ViewModeStorage<BoardData>,
private byColumn: ColumnMap,
private persistedData: BoardData,
private _statusAttribute: string) {}
get columns() {
return this._columns;
}
get statusAttribute() {
return this._statusAttribute;
}
getColumn(column: string) {
return this.byColumn.get(column);
}
async changeColumn(noteId: string, newColumn: string) {
await attributes.setLabel(noteId, this._statusAttribute, newColumn);
}
openNote(noteId: string) {
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
}
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after",
open: boolean = true) {
const { note } = await note_create.createNote(this._parentNoteId, {
activate: false,
targetBranchId: relativeToBranchId,
target: direction,
title: "New item"
});
if (!note) {
throw new Error("Failed to create note");
}
const { noteId } = note;
await this.changeColumn(noteId, column);
if (open) {
this.openNote(noteId);
}
return note;
}
async renameColumn(oldValue: string, newValue: string, noteIds: string[]) {
// Change the value in the notes.
await executeBulkActions(noteIds, [
{
name: "updateLabelValue",
labelName: this._statusAttribute,
labelValue: newValue
}
]);
// Rename the column in the persisted data.
for (const column of this.persistedData.columns || []) {
if (column.value === oldValue) {
column.value = newValue;
}
}
await this.viewStorage.store(this.persistedData);
}
async removeColumn(column: string) {
// Remove the value from the notes.
const noteIds = this.byColumn.get(column)?.map(item => item.note.noteId) || [];
await executeBulkActions(noteIds, [
{
name: "deleteLabel",
labelName: this._statusAttribute
}
]);
this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column);
this.viewStorage.store(this.persistedData);
}
async createColumn(columnValue: string) {
// Add the new column to persisted data if it doesn't exist
if (!this.persistedData.columns) {
this.persistedData.columns = [];
}
const existingColumn = this.persistedData.columns.find(col => col.value === columnValue);
if (!existingColumn) {
this.persistedData.columns.push({ value: columnValue });
await this.viewStorage.store(this.persistedData);
}
return columnValue;
}
async reorderColumns(newColumnOrder: string[]) {
// Update the column order in persisted data
if (!this.persistedData.columns) {
this.persistedData.columns = [];
}
// Create a map of existing column data
const columnDataMap = new Map();
this.persistedData.columns.forEach(col => {
columnDataMap.set(col.value, col);
});
// Reorder columns based on new order
this.persistedData.columns = newColumnOrder.map(columnValue => {
return columnDataMap.get(columnValue) || { value: columnValue };
});
// Update internal columns array
this._columns = newColumnOrder;
await this.viewStorage.store(this.persistedData);
}
async refresh(parentNote: FNote) {
// Refresh the API data by re-fetching from the parent note
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
this._statusAttribute = statusAttribute;
// Use the current in-memory persisted data instead of restoring from storage
// This ensures we don't lose recent updates like column renames
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, this.persistedData);
// Update internal state
this.byColumn = byColumn;
if (newPersistedData) {
this.persistedData = newPersistedData;
this.viewStorage.store(this.persistedData);
}
// Use the order from persistedData.columns, then add any new columns found
const orderedColumns = this.persistedData.columns?.map(col => col.value) || [];
const allColumns = Array.from(byColumn.keys());
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
this._columns = [...orderedColumns, ...newColumns];
}
static async build(parentNote: FNote, viewStorage: ViewModeStorage<BoardData>) {
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
let persistedData = await viewStorage.restore() ?? {};
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData);
// Use the order from persistedData.columns, then add any new columns found
const orderedColumns = persistedData.columns?.map(col => col.value) || [];
const allColumns = Array.from(byColumn.keys());
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
const columns = [...orderedColumns, ...newColumns];
if (newPersistedData) {
persistedData = newPersistedData;
viewStorage.store(persistedData);
}
return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute);
}
}

View File

@@ -0,0 +1,278 @@
import BoardApi from "./api";
import { DragContext, BaseDragHandler } from "./drag_types";
export class ColumnDragHandler implements BaseDragHandler {
private $container: JQuery<HTMLElement>;
private api: BoardApi;
private context: DragContext;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
context: DragContext,
) {
this.$container = $container;
this.api = api;
this.context = context;
}
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
const $titleEl = $columnEl.find('h3[data-column-value]');
$titleEl.attr("draggable", "true");
// Delay drag start to allow click detection
let dragStartTimer: number | null = null;
$titleEl.on("mousedown", (e) => {
// Don't interfere with editing mode or input field interactions
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
return;
}
// Clear any existing timer
if (dragStartTimer) {
clearTimeout(dragStartTimer);
dragStartTimer = null;
}
// Set a short delay before enabling dragging
dragStartTimer = window.setTimeout(() => {
$titleEl.attr("draggable", "true");
dragStartTimer = null;
}, 150);
});
$titleEl.on("mouseup mouseleave", (e) => {
// Don't interfere with editing mode
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
return;
}
// Cancel drag start timer on mouse up or leave
if (dragStartTimer) {
clearTimeout(dragStartTimer);
dragStartTimer = null;
}
});
$titleEl.on("dragstart", (e) => {
// Only start dragging if the target is not an input (for inline editing)
if ($(e.target).is('input') || $titleEl.hasClass('editing')) {
e.preventDefault();
return false;
}
this.context.draggedColumn = columnValue;
this.context.draggedColumnElement = $columnEl;
$columnEl.addClass("column-dragging");
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.effectAllowed = "move";
originalEvent.dataTransfer.setData("text/plain", columnValue);
}
// Prevent note dragging when column is being dragged
e.stopPropagation();
// Setup global drag tracking for better drop indicator positioning
this.setupGlobalColumnDragTracking();
});
$titleEl.on("dragend", () => {
$columnEl.removeClass("column-dragging");
this.context.draggedColumn = null;
this.context.draggedColumnElement = null;
this.cleanupColumnDropIndicators();
this.cleanupGlobalColumnDragTracking();
// Re-enable draggable
$titleEl.attr("draggable", "true");
});
}
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
$columnEl.on("dragover", (e) => {
// Only handle column drops when a column is being dragged
if (this.context.draggedColumn && !this.context.draggedNote) {
e.preventDefault();
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.dropEffect = "move";
}
// Don't highlight columns - we only care about the drop indicator position
}
});
$columnEl.on("drop", async (e) => {
if (this.context.draggedColumn && !this.context.draggedNote) {
e.preventDefault();
console.log("Column drop event triggered for column:", this.context.draggedColumn);
// Use the drop indicator position to determine where to place the column
await this.handleColumnDrop();
}
});
}
cleanup() {
this.cleanupColumnDropIndicators();
this.context.draggedColumn = null;
this.context.draggedColumnElement = null;
this.cleanupGlobalColumnDragTracking();
}
private setupGlobalColumnDragTracking() {
// Add container-level drag tracking for better indicator positioning
this.$container.on("dragover.columnDrag", (e) => {
if (this.context.draggedColumn) {
e.preventDefault();
const originalEvent = e.originalEvent as DragEvent;
this.showColumnDropIndicator(originalEvent.clientX);
}
});
// Add container-level drop handler for column reordering
this.$container.on("drop.columnDrag", async (e) => {
if (this.context.draggedColumn) {
e.preventDefault();
console.log("Container drop event triggered for column:", this.context.draggedColumn);
await this.handleColumnDrop();
}
});
}
private cleanupGlobalColumnDragTracking() {
this.$container.off("dragover.columnDrag");
this.$container.off("drop.columnDrag");
}
private cleanupColumnDropIndicators() {
// Remove column drop indicators
this.$container.find(".column-drop-indicator").remove();
}
private showColumnDropIndicator(mouseX: number) {
// Clean up existing indicators
this.cleanupColumnDropIndicators();
// Get all columns (excluding the dragged one if it exists)
let $allColumns = this.$container.find('.board-column');
if (this.context.draggedColumnElement) {
$allColumns = $allColumns.not(this.context.draggedColumnElement);
}
let $targetColumn: JQuery<HTMLElement> = $();
let insertBefore = false;
// Find which column the mouse is closest to
$allColumns.each((_, columnEl) => {
const $column = $(columnEl);
const rect = columnEl.getBoundingClientRect();
const columnMiddle = rect.left + rect.width / 2;
if (mouseX >= rect.left && mouseX <= rect.right) {
// Mouse is over this column
$targetColumn = $column;
insertBefore = mouseX < columnMiddle;
return false; // Break the loop
}
});
// If no column found under mouse, find the closest one
if ($targetColumn.length === 0) {
let closestDistance = Infinity;
$allColumns.each((_, columnEl) => {
const $column = $(columnEl);
const rect = columnEl.getBoundingClientRect();
const columnCenter = rect.left + rect.width / 2;
const distance = Math.abs(mouseX - columnCenter);
if (distance < closestDistance) {
closestDistance = distance;
$targetColumn = $column;
insertBefore = mouseX < columnCenter;
}
});
}
if ($targetColumn.length > 0) {
const $dropIndicator = $("<div>").addClass("column-drop-indicator");
if (insertBefore) {
$targetColumn.before($dropIndicator);
} else {
$targetColumn.after($dropIndicator);
}
$dropIndicator.addClass("show");
}
}
private async handleColumnDrop() {
console.log("handleColumnDrop called for:", this.context.draggedColumn);
if (!this.context.draggedColumn || !this.context.draggedColumnElement) {
console.log("No dragged column or element found");
return;
}
try {
// Find the drop indicator to determine insert position
const $dropIndicator = this.$container.find(".column-drop-indicator.show");
console.log("Drop indicator found:", $dropIndicator.length > 0);
if ($dropIndicator.length > 0) {
// Get current column order from the API (source of truth)
const currentOrder = [...this.api.columns];
let newOrder = [...currentOrder];
// Remove dragged column from current position
newOrder = newOrder.filter(col => col !== this.context.draggedColumn);
// Determine insertion position based on drop indicator position
const $nextColumn = $dropIndicator.next('.board-column');
const $prevColumn = $dropIndicator.prev('.board-column');
let insertIndex = -1;
if ($nextColumn.length > 0) {
// Insert before the next column
const nextColumnValue = $nextColumn.attr('data-column');
if (nextColumnValue) {
insertIndex = newOrder.indexOf(nextColumnValue);
}
} else if ($prevColumn.length > 0) {
// Insert after the previous column
const prevColumnValue = $prevColumn.attr('data-column');
if (prevColumnValue) {
insertIndex = newOrder.indexOf(prevColumnValue) + 1;
}
} else {
// Insert at the beginning
insertIndex = 0;
}
// Insert the dragged column at the determined position
if (insertIndex >= 0 && insertIndex <= newOrder.length) {
newOrder.splice(insertIndex, 0, this.context.draggedColumn);
} else {
// Fallback: insert at the end
newOrder.push(this.context.draggedColumn);
}
// Update column order in API
await this.api.reorderColumns(newOrder);
} else {
console.warn("No drop indicator found for column drop");
}
} catch (error) {
console.error("Failed to reorder columns:", error);
} finally {
this.cleanupColumnDropIndicators();
}
}
}

View File

@@ -0,0 +1,7 @@
export interface BoardColumnData {
value: string;
}
export interface BoardData {
columns?: BoardColumnData[];
}

View File

@@ -0,0 +1,93 @@
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu.js";
import link_context_menu from "../../../menus/link_context_menu.js";
import branches from "../../../services/branches.js";
import dialog from "../../../services/dialog.js";
import { t } from "../../../services/i18n.js";
import BoardApi from "./api.js";
import type BoardView from "./index.js";
interface ShowNoteContextMenuArgs {
$container: JQuery<HTMLElement>;
api: BoardApi;
boardView: BoardView;
}
export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) {
$container.on("contextmenu", ".board-note", showNoteContextMenu);
$container.on("contextmenu", ".board-column h3", showColumnContextMenu);
function showColumnContextMenu(event: ContextMenuEvent) {
event.preventDefault();
event.stopPropagation();
const $el = $(event.currentTarget);
const column = $el.closest(".board-column").data("column");
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
{
title: t("board_view.delete-column"),
uiIcon: "bx bx-trash",
async handler() {
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
if (!confirmed) {
return;
}
await api.removeColumn(column);
}
}
],
selectMenuItemHandler() {}
});
}
function showNoteContextMenu(event: ContextMenuEvent) {
event.preventDefault();
event.stopPropagation();
const $el = $(event.currentTarget);
const noteId = $el.data("note-id");
const branchId = $el.data("branch-id");
const column = $el.closest(".board-column").data("column");
if (!noteId) return;
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
...link_context_menu.getItems(),
{ title: "----" },
{
title: t("board_view.move-to"),
uiIcon: "bx bx-transfer",
items: api.columns.map(columnToMoveTo => ({
title: columnToMoveTo,
enabled: columnToMoveTo !== column,
handler: () => api.changeColumn(noteId, columnToMoveTo)
}))
},
{ title: "----" },
{
title: t("board_view.insert-above"),
uiIcon: "bx bx-list-plus",
handler: () => boardView.insertItemAtPosition(column, branchId, "before")
},
{
title: t("board_view.insert-below"),
uiIcon: "bx bx-empty",
handler: () => boardView.insertItemAtPosition(column, branchId, "after")
},
{ title: "----" },
{
title: t("board_view.delete-note"),
uiIcon: "bx bx-trash",
handler: () => branches.deleteNotes([ branchId ], false, false)
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
});
}
}

View File

@@ -0,0 +1,84 @@
import FBranch from "../../../entities/fbranch";
import FNote from "../../../entities/fnote";
import { BoardData } from "./config";
export type ColumnMap = Map<string, {
branch: FBranch;
note: FNote;
}[]>;
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
const byColumn: ColumnMap = new Map();
// First, scan all notes to find what columns actually exist
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
// Get all columns that exist in the notes
const columnsFromNotes = [...byColumn.keys()];
// Get existing persisted columns and preserve their order
const existingPersistedColumns = persistedData.columns || [];
const existingColumnValues = existingPersistedColumns.map(c => c.value);
// Find truly new columns (exist in notes but not in persisted data)
const newColumnValues = columnsFromNotes.filter(col => !existingColumnValues.includes(col));
// Build the complete correct column list: existing + new
const allColumns = [
...existingPersistedColumns, // Preserve existing order
...newColumnValues.map(value => ({ value })) // Add new columns
];
// Remove duplicates (just in case) and ensure we only keep columns that exist in notes or are explicitly preserved
const deduplicatedColumns = allColumns.filter((column, index) => {
const firstIndex = allColumns.findIndex(c => c.value === column.value);
return firstIndex === index; // Keep only the first occurrence
});
// Ensure all persisted columns have empty arrays in byColumn (even if no notes use them)
for (const column of deduplicatedColumns) {
if (!byColumn.has(column.value)) {
byColumn.set(column.value, []);
}
}
// Return updated persisted data only if there were changes
let newPersistedData: BoardData | undefined;
const hasChanges = newColumnValues.length > 0 ||
existingPersistedColumns.length !== deduplicatedColumns.length ||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
if (hasChanges) {
newPersistedData = {
...persistedData,
columns: deduplicatedColumns
};
}
return {
byColumn,
newPersistedData
};
}
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
for (const branch of branches) {
const note = await branch.getNote();
if (!note) {
continue;
}
const group = note.getLabelValue(groupByColumn);
if (!group) {
continue;
}
if (!byColumn.has(group)) {
byColumn.set(group, []);
}
byColumn.get(group)!.push({
branch,
note
});
}
}

View File

@@ -0,0 +1,539 @@
import { BoardDragHandler } from "./drag_handler";
import BoardApi from "./api";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import ViewModeStorage from "../view_mode_storage";
import { BoardData } from "./config";
import { t } from "../../../services/i18n.js";
export interface BoardState {
columns: { [key: string]: { note: any; branch: any }[] };
columnOrder: string[];
}
export class DifferentialBoardRenderer {
private $container: JQuery<HTMLElement>;
private api: BoardApi;
private dragHandler: BoardDragHandler;
private lastState: BoardState | null = null;
private onCreateNewItem: (column: string) => void;
private updateTimeout: number | null = null;
private pendingUpdate = false;
private parentNote: FNote;
private viewStorage: ViewModeStorage<BoardData>;
private onRefreshApi: () => Promise<void>;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
dragHandler: BoardDragHandler,
onCreateNewItem: (column: string) => void,
parentNote: FNote,
viewStorage: ViewModeStorage<BoardData>,
onRefreshApi: () => Promise<void>
) {
this.$container = $container;
this.api = api;
this.dragHandler = dragHandler;
this.onCreateNewItem = onCreateNewItem;
this.parentNote = parentNote;
this.viewStorage = viewStorage;
this.onRefreshApi = onRefreshApi;
}
async renderBoard(refreshApi = false): Promise<void> {
// Refresh API data if requested
if (refreshApi) {
await this.onRefreshApi();
}
// Debounce rapid updates
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
}
this.updateTimeout = window.setTimeout(async () => {
await this.performUpdate();
this.updateTimeout = null;
}, 16); // ~60fps
}
private async performUpdate(): Promise<void> {
// Clean up any stray drag indicators before updating
this.dragHandler.cleanup();
const currentState = this.getCurrentState();
if (!this.lastState) {
// First render - do full render
await this.fullRender(currentState);
} else {
// Differential render - only update what changed
await this.differentialRender(this.lastState, currentState);
}
this.lastState = currentState;
}
private getCurrentState(): BoardState {
const columns: { [key: string]: { note: any; branch: any }[] } = {};
const columnOrder: string[] = [];
for (const column of this.api.columns) {
columnOrder.push(column);
columns[column] = this.api.getColumn(column) || [];
}
return { columns, columnOrder };
}
private async fullRender(state: BoardState): Promise<void> {
this.$container.empty();
for (const column of state.columnOrder) {
const columnItems = state.columns[column];
const $columnEl = this.createColumn(column, columnItems);
this.$container.append($columnEl);
}
this.addAddColumnButton();
}
private async differentialRender(oldState: BoardState, newState: BoardState): Promise<void> {
// Store scroll positions before making changes
const scrollPositions = this.saveScrollPositions();
// Handle column additions/removals
this.updateColumns(oldState, newState);
// Handle card updates within existing columns
for (const column of newState.columnOrder) {
this.updateColumnCards(column, oldState.columns[column] || [], newState.columns[column]);
}
// Restore scroll positions
this.restoreScrollPositions(scrollPositions);
}
private saveScrollPositions(): { [column: string]: number } {
const positions: { [column: string]: number } = {};
this.$container.find('.board-column').each((_, el) => {
const column = $(el).attr('data-column');
if (column) {
positions[column] = el.scrollTop;
}
});
return positions;
}
private restoreScrollPositions(positions: { [column: string]: number }): void {
this.$container.find('.board-column').each((_, el) => {
const column = $(el).attr('data-column');
if (column && positions[column] !== undefined) {
el.scrollTop = positions[column];
}
});
}
private updateColumns(oldState: BoardState, newState: BoardState): void {
// Check if column order has changed
const orderChanged = !this.arraysEqual(oldState.columnOrder, newState.columnOrder);
if (orderChanged) {
// If order changed, we need to reorder the columns in the DOM
this.reorderColumns(newState.columnOrder);
}
// Remove columns that no longer exist
for (const oldColumn of oldState.columnOrder) {
if (!newState.columnOrder.includes(oldColumn)) {
this.$container.find(`[data-column="${oldColumn}"]`).remove();
}
}
// Add new columns
for (const newColumn of newState.columnOrder) {
if (!oldState.columnOrder.includes(newColumn)) {
const columnItems = newState.columns[newColumn];
const $columnEl = this.createColumn(newColumn, columnItems);
// Insert at correct position
const insertIndex = newState.columnOrder.indexOf(newColumn);
const $existingColumns = this.$container.find('.board-column');
if (insertIndex === 0) {
this.$container.prepend($columnEl);
} else if (insertIndex >= $existingColumns.length) {
this.$container.find('.board-add-column').before($columnEl);
} else {
$($existingColumns[insertIndex - 1]).after($columnEl);
}
}
}
}
private arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((val, index) => val === b[index]);
}
private reorderColumns(newOrder: string[]): void {
// Get all existing column elements
const $columns = this.$container.find('.board-column');
const $addColumnButton = this.$container.find('.board-add-column');
// Create a map of column elements by their data-column attribute
const columnElements = new Map<string, JQuery<HTMLElement>>();
$columns.each((_, el) => {
const $el = $(el);
const columnValue = $el.attr('data-column');
if (columnValue) {
columnElements.set(columnValue, $el);
}
});
// Remove all columns from DOM (but keep references)
$columns.detach();
// Re-insert columns in the new order
let $insertAfter: JQuery<HTMLElement> | null = null;
for (const columnValue of newOrder) {
const $columnEl = columnElements.get(columnValue);
if ($columnEl) {
if ($insertAfter) {
$insertAfter.after($columnEl);
} else {
// Insert at the beginning
this.$container.prepend($columnEl);
}
$insertAfter = $columnEl;
}
}
// Ensure add column button is at the end
if ($addColumnButton.length) {
this.$container.append($addColumnButton);
}
}
private updateColumnCards(column: string, oldCards: { note: any; branch: any }[], newCards: { note: any; branch: any }[]): void {
const $column = this.$container.find(`[data-column="${column}"]`);
if (!$column.length) return;
const $cardContainer = $column;
const oldCardIds = oldCards.map(item => item.note.noteId);
const newCardIds = newCards.map(item => item.note.noteId);
// Remove cards that no longer exist
$cardContainer.find('.board-note').each((_, el) => {
const noteId = $(el).attr('data-note-id');
if (noteId && !newCardIds.includes(noteId)) {
$(el).addClass('fade-out');
setTimeout(() => $(el).remove(), 150);
}
});
// Add or update cards
for (let i = 0; i < newCards.length; i++) {
const item = newCards[i];
const noteId = item.note.noteId;
const $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`);
const isNewCard = !oldCardIds.includes(noteId);
if ($existingCard.length) {
// Check for changes in title, icon, or color
const currentTitle = $existingCard.text().trim();
const currentIconClass = $existingCard.attr('data-icon-class');
const currentColorClass = $existingCard.attr('data-color-class') || '';
const newIconClass = item.note.getIcon();
const newColorClass = item.note.getColorClass() || '';
let hasChanges = false;
// Update title if changed
if (currentTitle !== item.note.title) {
$existingCard.contents().filter(function() {
return this.nodeType === 3; // Text nodes
}).remove();
$existingCard.append(document.createTextNode(item.note.title));
hasChanges = true;
}
// Update icon if changed
if (currentIconClass !== newIconClass) {
const $icon = $existingCard.find('.icon');
$icon.removeClass().addClass('icon').addClass(newIconClass);
$existingCard.attr('data-icon-class', newIconClass);
hasChanges = true;
}
// Update color if changed
if (currentColorClass !== newColorClass) {
// Remove old color class if it exists
if (currentColorClass) {
$existingCard.removeClass(currentColorClass);
}
// Add new color class if it exists
if (newColorClass) {
$existingCard.addClass(newColorClass);
}
$existingCard.attr('data-color-class', newColorClass);
hasChanges = true;
}
// Add subtle animation if there were changes
if (hasChanges) {
$existingCard.addClass('card-updated');
setTimeout(() => $existingCard.removeClass('card-updated'), 300);
}
// Ensure card is in correct position
this.ensureCardPosition($existingCard, i, $cardContainer);
} else {
// Create new card
const $newCard = this.createCard(item.note, item.branch, column);
$newCard.addClass('fade-in').css('opacity', '0');
// Insert at correct position
if (i === 0) {
$cardContainer.find('h3').after($newCard);
} else {
const $prevCard = $cardContainer.find('.board-note').eq(i - 1);
if ($prevCard.length) {
$prevCard.after($newCard);
} else {
$cardContainer.find('.board-new-item').before($newCard);
}
}
// Trigger fade in animation
setTimeout(() => $newCard.css('opacity', '1'), 10);
}
}
}
private ensureCardPosition($card: JQuery<HTMLElement>, targetIndex: number, $container: JQuery<HTMLElement>): void {
const $allCards = $container.find('.board-note');
const currentIndex = $allCards.index($card);
if (currentIndex !== targetIndex) {
if (targetIndex === 0) {
$container.find('h3').after($card);
} else {
const $targetPrev = $allCards.eq(targetIndex - 1);
if ($targetPrev.length) {
$targetPrev.after($card);
}
}
}
}
private createColumn(column: string, columnItems: { note: any; branch: any }[]): JQuery<HTMLElement> {
const $columnEl = $("<div>")
.addClass("board-column")
.attr("data-column", column);
// Create header
const $titleEl = $("<h3>").attr("data-column-value", column);
// Create title text
const $titleText = $("<span>").text(column);
// Create edit icon
const $editIcon = $("<span>")
.addClass("edit-icon icon bx bx-edit-alt")
.attr("title", "Click to edit column title");
$titleEl.append($titleText, $editIcon);
$columnEl.append($titleEl);
// Setup column dragging
this.dragHandler.setupColumnDrag($columnEl, column);
// Handle wheel events for scrolling
$columnEl.on("wheel", (event) => {
const el = $columnEl[0];
const needsScroll = el.scrollHeight > el.clientHeight;
if (needsScroll) {
event.stopPropagation();
}
});
// Setup drop zones for both notes and columns
this.dragHandler.setupNoteDropZone($columnEl, column);
this.dragHandler.setupColumnDropZone($columnEl);
// Add cards
for (const item of columnItems) {
if (item.note) {
const $noteEl = this.createCard(item.note, item.branch, column);
$columnEl.append($noteEl);
}
}
// Add "New item" button
const $newItemEl = $("<div>")
.addClass("board-new-item")
.attr("data-column", column)
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.new-item")}`);
$newItemEl.on("click", () => this.onCreateNewItem(column));
$columnEl.append($newItemEl);
return $columnEl;
}
private createCard(note: any, branch: any, column: string): JQuery<HTMLElement> {
const $iconEl = $("<span>")
.addClass("icon")
.addClass(note.getIcon());
const colorClass = note.getColorClass() || '';
const $noteEl = $("<div>")
.addClass("board-note")
.attr("data-note-id", note.noteId)
.attr("data-branch-id", branch.branchId)
.attr("data-current-column", column)
.attr("data-icon-class", note.getIcon())
.attr("data-color-class", colorClass)
.text(note.title);
// Add color class to the card if it exists
if (colorClass) {
$noteEl.addClass(colorClass);
}
$noteEl.prepend($iconEl);
$noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }));
// Setup drag functionality
this.dragHandler.setupNoteDrag($noteEl, note, branch);
return $noteEl;
}
private addAddColumnButton(): void {
if (this.$container.find('.board-add-column').length === 0) {
const $addColumnEl = $("<div>")
.addClass("board-add-column")
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.add-column")}`);
this.$container.append($addColumnEl);
}
}
forceFullRender(): void {
this.lastState = null;
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
}
}
async flushPendingUpdates(): Promise<void> {
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
await this.performUpdate();
}
}
startInlineEditing(noteId: string): void {
// Use setTimeout to ensure the card is rendered before trying to edit it
setTimeout(() => {
const $card = this.$container.find(`[data-note-id="${noteId}"]`);
if ($card.length) {
this.makeCardEditable($card, noteId);
}
}, 100);
}
private makeCardEditable($card: JQuery<HTMLElement>, noteId: string): void {
if ($card.hasClass('editing')) {
return; // Already editing
}
// Get the current title (get text without icon)
const $icon = $card.find('.icon');
const currentTitle = $card.text().trim();
// Add editing class and store original click handler
$card.addClass('editing');
$card.off('click'); // Remove any existing click handlers temporarily
// Create input element
const $input = $('<input>')
.attr('type', 'text')
.val(currentTitle)
.css({
background: 'transparent',
border: 'none',
outline: 'none',
fontFamily: 'inherit',
fontSize: 'inherit',
color: 'inherit',
flex: '1',
minWidth: '0',
padding: '0',
marginLeft: '0.25em'
});
// Create a flex container to keep icon and input inline
const $editContainer = $('<div>')
.css({
display: 'flex',
alignItems: 'center',
width: '100%'
});
// Replace content with icon + input in flex container
$editContainer.append($icon.clone(), $input);
$card.empty().append($editContainer);
$input.focus().select();
const finishEdit = async (save = true) => {
if (!$card.hasClass('editing')) {
return; // Already finished
}
$card.removeClass('editing');
let finalTitle = currentTitle;
if (save) {
const newTitle = $input.val() as string;
if (newTitle.trim() && newTitle !== currentTitle) {
try {
// Update the note title using the board view's server call
import('../../../services/server').then(async ({ default: server }) => {
await server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
finalTitle = newTitle.trim();
});
} catch (error) {
console.error("Failed to update note title:", error);
}
}
}
// Restore the card content
const iconClass = $card.attr('data-icon-class') || 'bx bx-file';
const $newIcon = $('<span>').addClass('icon').addClass(iconClass);
$card.text(finalTitle);
$card.prepend($newIcon);
// Re-attach click handler for quick edit (for existing cards)
$card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }));
};
$input.on('blur', () => finishEdit(true));
$input.on('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finishEdit(true);
} else if (e.key === 'Escape') {
e.preventDefault();
finishEdit(false);
}
});
}
}

View File

@@ -0,0 +1,45 @@
import BoardApi from "./api";
import { DragContext } from "./drag_types";
import { NoteDragHandler } from "./note_drag_handler";
import { ColumnDragHandler } from "./column_drag_handler";
export class BoardDragHandler {
private noteDragHandler: NoteDragHandler;
private columnDragHandler: ColumnDragHandler;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
context: DragContext,
) {
// Initialize specialized drag handlers
this.noteDragHandler = new NoteDragHandler($container, api, context);
this.columnDragHandler = new ColumnDragHandler($container, api, context);
}
// Note drag methods - delegate to NoteDragHandler
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
this.noteDragHandler.setupNoteDrag($noteEl, note, branch);
}
setupNoteDropZone($columnEl: JQuery<HTMLElement>, column: string) {
this.noteDragHandler.setupNoteDropZone($columnEl, column);
}
// Column drag methods - delegate to ColumnDragHandler
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
this.columnDragHandler.setupColumnDrag($columnEl, columnValue);
}
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
this.columnDragHandler.setupColumnDropZone($columnEl);
}
cleanup() {
this.noteDragHandler.cleanup();
this.columnDragHandler.cleanup();
}
}
// Export the drag context type for external use
export type { DragContext } from "./drag_types";

View File

@@ -0,0 +1,11 @@
export interface DragContext {
draggedNote: any;
draggedBranch: any;
draggedNoteElement: JQuery<HTMLElement> | null;
draggedColumn: string | null;
draggedColumnElement: JQuery<HTMLElement> | null;
}
export interface BaseDragHandler {
cleanup(): void;
}

View File

@@ -0,0 +1,650 @@
import { setupHorizontalScrollViaWheel } from "../../widget_utils";
import ViewMode, { ViewModeArgs } from "../view_mode";
import noteCreateService from "../../../services/note_create";
import { EventData } from "../../../components/app_context";
import { BoardData } from "./config";
import SpacedUpdate from "../../../services/spaced_update";
import { setupContextMenu } from "./context_menu";
import BoardApi from "./api";
import { BoardDragHandler, DragContext } from "./drag_handler";
import { DifferentialBoardRenderer } from "./differential_renderer";
const TPL = /*html*/`
<div class="board-view">
<style>
.board-view {
overflow-x: auto;
position: relative;
height: 100%;
user-select: none;
}
.board-view-container {
height: 100%;
display: flex;
gap: 1em;
padding: 1em;
padding-bottom: 0;
align-items: flex-start;
}
.board-view-container .board-column {
width: 250px;
flex-shrink: 0;
border: 2px solid transparent;
border-radius: 8px;
padding: 0.5em;
background-color: var(--accented-background-color);
transition: border-color 0.2s ease;
overflow-y: auto;
max-height: 100%;
}
.board-view-container .board-column.drag-over {
border-color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-view-container .board-column h3 {
font-size: 1em;
margin-bottom: 0.75em;
padding: 0.5em 0.5em 0.5em 0.5em;
border-bottom: 1px solid var(--main-border-color);
cursor: grab;
position: relative;
transition: background-color 0.2s ease, border-radius 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background-color: transparent;
}
.board-view-container .board-column h3:active {
cursor: grabbing;
}
.board-view-container .board-column h3.editing {
cursor: default;
}
.board-view-container .board-column h3:hover {
background-color: var(--hover-item-background-color);
border-radius: 4px;
}
.board-view-container .board-column h3.editing {
background-color: var(--main-background-color);
border: 1px solid var(--main-text-color);
border-radius: 4px;
}
.board-view-container .board-column.column-dragging {
opacity: 0.6;
transform: scale(0.98);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.board-view-container .board-column h3 input {
background: transparent;
border: none;
outline: none;
font-size: inherit;
font-weight: inherit;
color: inherit;
width: 100%;
font-family: inherit;
}
.board-view-container .board-column h3 .edit-icon {
opacity: 0;
margin-left: 0.5em;
transition: opacity 0.2s ease;
color: var(--muted-text-color);
}
.board-view-container .board-column h3:hover .edit-icon {
opacity: 1;
}
.board-view-container .board-column h3.editing .edit-icon {
display: none;
}
.board-view-container .board-note {
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25);
margin: 0.65em 0;
padding: 0.5em;
border-radius: 5px;
cursor: move;
position: relative;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease;
opacity: 1;
}
.board-view-container .board-note.fade-in {
animation: fadeIn 0.15s ease-in;
}
.board-view-container .board-note.fade-out {
animation: fadeOut 0.15s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
.board-view-container .board-note.card-updated {
animation: cardUpdate 0.3s ease-in-out;
}
@keyframes cardUpdate {
0% { transform: scale(1); }
50% { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
100% { transform: scale(1); }
}
.board-view-container .board-note:hover {
transform: translateY(-2px);
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
}
.board-view-container .board-note.dragging {
opacity: 0.8;
transform: rotate(5deg);
z-index: 1000;
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
}
.board-view-container .board-note.editing {
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
border-color: var(--main-text-color);
}
.board-view-container .board-note.editing input {
background: transparent;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
color: inherit;
width: 100%;
padding: 0;
}
.board-view-container .board-note .icon {
margin-right: 0.25em;
}
.board-drop-indicator {
height: 3px;
background-color: var(--main-text-color);
border-radius: 2px;
margin: 0.25em 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.board-drop-indicator.show {
opacity: 1;
}
.column-drop-indicator {
width: 4px;
background-color: var(--main-text-color);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
height: 100%;
z-index: 1000;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.column-drop-indicator.show {
opacity: 1;
}
.board-new-item {
margin-top: 0.5em;
padding: 0.5em;
border-radius: 5px;
color: var(--muted-text-color);
cursor: pointer;
transition: all 0.2s ease;
background-color: transparent;
}
.board-new-item:hover {
border-color: var(--main-text-color);
color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-new-item .icon {
margin-right: 0.25em;
}
.board-add-column {
width: 180px;
flex-shrink: 0;
height: 60px;
border-radius: 8px;
padding: 0.5em;
background-color: var(--accented-background-color);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--muted-text-color);
font-size: 0.9em;
align-self: flex-start;
}
.board-add-column:hover {
border-color: var(--main-text-color);
color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-add-column .icon {
margin-right: 0.5em;
font-size: 1.2em;
}
.board-drag-preview {
position: fixed;
z-index: 10000;
pointer-events: none;
opacity: 0.8;
transform: rotate(5deg);
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-radius: 5px;
padding: 0.5em;
font-size: 0.9em;
max-width: 200px;
word-wrap: break-word;
}
</style>
<div class="board-view-container"></div>
</div>
`;
export default class BoardView extends ViewMode<BoardData> {
private $root: JQuery<HTMLElement>;
private $container: JQuery<HTMLElement>;
private spacedUpdate: SpacedUpdate;
private dragContext: DragContext;
private persistentData: BoardData;
private api?: BoardApi;
private dragHandler?: BoardDragHandler;
private renderer?: DifferentialBoardRenderer;
constructor(args: ViewModeArgs) {
super(args, "board");
this.$root = $(TPL);
setupHorizontalScrollViaWheel(this.$root);
this.$container = this.$root.find(".board-view-container");
this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
this.persistentData = {
columns: []
};
this.dragContext = {
draggedNote: null,
draggedBranch: null,
draggedNoteElement: null,
draggedColumn: null,
draggedColumnElement: null
};
args.$parent.append(this.$root);
}
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
if (!this.renderer) {
// First time setup
this.$container.empty();
await this.initializeRenderer();
}
await this.renderer!.renderBoard();
return this.$root;
}
private async initializeRenderer() {
this.api = await BoardApi.build(this.parentNote, this.viewStorage);
this.dragHandler = new BoardDragHandler(
this.$container,
this.api,
this.dragContext
);
this.renderer = new DifferentialBoardRenderer(
this.$container,
this.api,
this.dragHandler,
(column: string) => this.createNewItem(column),
this.parentNote,
this.viewStorage,
() => this.refreshApi()
);
setupContextMenu({
$container: this.$container,
api: this.api,
boardView: this
});
// Setup column title editing and add column functionality
this.setupBoardInteractions();
}
private async refreshApi(): Promise<void> {
if (!this.api) {
throw new Error("API not initialized");
}
await this.api.refresh(this.parentNote);
}
private setupBoardInteractions() {
// Handle column title editing with click detection that works with dragging
this.$container.on('mousedown', 'h3[data-column-value]', (e) => {
const $titleEl = $(e.currentTarget);
// Don't interfere with editing mode
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
return;
}
const startTime = Date.now();
let hasMoved = false;
const startX = e.clientX;
const startY = e.clientY;
const handleMouseMove = (moveEvent: JQuery.MouseMoveEvent) => {
const deltaX = Math.abs(moveEvent.clientX - startX);
const deltaY = Math.abs(moveEvent.clientY - startY);
if (deltaX > 5 || deltaY > 5) {
hasMoved = true;
}
};
const handleMouseUp = (upEvent: JQuery.MouseUpEvent) => {
const duration = Date.now() - startTime;
$(document).off('mousemove', handleMouseMove);
$(document).off('mouseup', handleMouseUp);
// If it was a quick click without much movement, treat as edit request
if (duration < 500 && !hasMoved && upEvent.button === 0) {
const columnValue = $titleEl.attr('data-column-value');
if (columnValue) {
const columnItems = this.api?.getColumn(columnValue) || [];
this.startEditingColumnTitle($titleEl, columnValue, columnItems);
}
}
};
$(document).on('mousemove', handleMouseMove);
$(document).on('mouseup', handleMouseUp);
});
// Handle add column button
this.$container.on('click', '.board-add-column', (e) => {
e.stopPropagation();
this.startCreatingNewColumn($(e.currentTarget));
});
}
private createTitleStructure(title: string): { $titleText: JQuery<HTMLElement>; $editIcon: JQuery<HTMLElement> } {
const $titleText = $("<span>").text(title);
const $editIcon = $("<span>")
.addClass("edit-icon icon bx bx-edit-alt")
.attr("title", "Click to edit column title");
return { $titleText, $editIcon };
}
private startEditingColumnTitle($titleEl: JQuery<HTMLElement>, columnValue: string, columnItems: { branch: any; note: any; }[]) {
if ($titleEl.hasClass("editing")) {
return; // Already editing
}
const $titleSpan = $titleEl.find("span").first(); // Get the text span
const currentTitle = $titleSpan.text();
$titleEl.addClass("editing");
// Disable dragging while editing
$titleEl.attr("draggable", "false");
const $input = $("<input>")
.attr("type", "text")
.val(currentTitle)
.attr("placeholder", "Column title");
// Prevent events from bubbling to parent drag handlers
$input.on('mousedown mouseup click', (e) => {
e.stopPropagation();
});
$titleEl.empty().append($input);
$input.focus().select();
const finishEdit = async (save: boolean = true) => {
if (!$titleEl.hasClass("editing")) {
return; // Already finished
}
$titleEl.removeClass("editing");
// Re-enable dragging after editing
$titleEl.attr("draggable", "true");
let finalTitle = currentTitle;
if (save) {
const newTitle = $input.val() as string;
if (newTitle.trim() && newTitle !== currentTitle) {
await this.renameColumn(columnValue, newTitle.trim(), columnItems);
finalTitle = newTitle.trim();
}
}
// Recreate the title structure
const { $titleText, $editIcon } = this.createTitleStructure(finalTitle);
$titleEl.empty().append($titleText, $editIcon);
};
$input.on("blur", () => finishEdit(true));
$input.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
finishEdit(true);
} else if (e.key === "Escape") {
e.preventDefault();
finishEdit(false);
}
});
}
private async renameColumn(oldValue: string, newValue: string, columnItems: { branch: any; note: any; }[]) {
try {
// Get all note IDs in this column
const noteIds = columnItems.map(item => item.note.noteId);
// Use the API to rename the column (update all notes)
// This will trigger onEntitiesReloaded which will automatically refresh the board
await this.api?.renameColumn(oldValue, newValue, noteIds);
} catch (error) {
console.error("Failed to rename column:", error);
}
}
private async createNewItem(column: string) {
try {
// Get the parent note path
const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note
const { note: newNote } = await noteCreateService.createNote(parentNotePath, {
activate: false,
title: "New item"
});
if (newNote) {
// Set the status label to place it in the correct column
await this.api?.changeColumn(newNote.noteId, column);
// Refresh the board to show the new item
await this.renderList();
// Start inline editing of the newly created card
this.startInlineEditingCard(newNote.noteId);
}
} catch (error) {
console.error("Failed to create new item:", error);
}
}
async insertItemAtPosition(column: string, relativeToBranchId: string, direction: "before" | "after"): Promise<void> {
try {
// Create the note without opening it
const newNote = await this.api?.insertRowAtPosition(column, relativeToBranchId, direction, false);
if (newNote) {
// Refresh the board to show the new item
await this.renderList();
// Start inline editing of the newly created card
this.startInlineEditingCard(newNote.noteId);
}
} catch (error) {
console.error("Failed to insert new item:", error);
}
}
private startInlineEditingCard(noteId: string) {
this.renderer?.startInlineEditing(noteId);
}
forceFullRefresh() {
this.renderer?.forceFullRender();
return this.renderList();
}
private startCreatingNewColumn($addColumnEl: JQuery<HTMLElement>) {
if ($addColumnEl.hasClass("editing")) {
return; // Already editing
}
$addColumnEl.addClass("editing");
const $input = $("<input>")
.attr("type", "text")
.attr("placeholder", "Enter column name...")
.css({
background: "var(--main-background-color)",
border: "1px solid var(--main-text-color)",
borderRadius: "4px",
padding: "0.5em",
color: "var(--main-text-color)",
fontFamily: "inherit",
fontSize: "inherit",
width: "100%",
textAlign: "center"
});
$addColumnEl.empty().append($input);
$input.focus();
const finishEdit = async (save: boolean = true) => {
if (!$addColumnEl.hasClass("editing")) {
return; // Already finished
}
$addColumnEl.removeClass("editing");
if (save) {
const columnName = $input.val() as string;
if (columnName.trim()) {
await this.createNewColumn(columnName.trim());
}
}
// Restore the add button
$addColumnEl.html('<span class="icon bx bx-plus"></span>Add Column');
};
$input.on("blur", () => finishEdit(true));
$input.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
finishEdit(true);
} else if (e.key === "Escape") {
e.preventDefault();
finishEdit(false);
}
});
}
private async createNewColumn(columnName: string) {
try {
// Check if column already exists
if (this.api?.columns.includes(columnName)) {
console.warn("A column with this name already exists.");
return;
}
// Create the new column
await this.api?.createColumn(columnName);
// Refresh the board to show the new column
await this.renderList();
} catch (error) {
console.error("Failed to create new column:", error);
}
}
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
// Check if any changes affect our board
const hasRelevantChanges =
// React to changes in status attribute for notes in this board
loadResults.getAttributeRows().some(attr => attr.name === this.api?.statusAttribute && this.noteIds.includes(attr.noteId!)) ||
// React to changes in note title
loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) ||
// React to changes in branches for subchildren (e.g., moved, added, or removed notes)
loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!)) ||
// React to changes in note icon or color.
loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && this.noteIds.includes(attr.noteId ?? "")) ||
// React to attachment change
loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json") ||
// React to changes in "groupBy"
loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === this.parentNote.noteId);
if (hasRelevantChanges && this.renderer) {
// Use differential rendering with API refresh
await this.renderer.renderBoard(true);
}
// Don't trigger full view refresh - let differential renderer handle it
return false;
}
private onSave() {
this.viewStorage.store(this.persistentData);
}
}

View File

@@ -0,0 +1,322 @@
import branchService from "../../../services/branches";
import BoardApi from "./api";
import { DragContext, BaseDragHandler } from "./drag_types";
export class NoteDragHandler implements BaseDragHandler {
private $container: JQuery<HTMLElement>;
private api: BoardApi;
private context: DragContext;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
context: DragContext,
) {
this.$container = $container;
this.api = api;
this.context = context;
}
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
$noteEl.attr("draggable", "true");
// Mouse drag events
this.setupMouseDrag($noteEl, note, branch);
// Touch drag events
this.setupTouchDrag($noteEl, note, branch);
}
setupNoteDropZone($columnEl: JQuery<HTMLElement>, column: string) {
$columnEl.on("dragover", (e) => {
// Only handle note drops when a note is being dragged
if (this.context.draggedNote && !this.context.draggedColumn) {
e.preventDefault();
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.dropEffect = "move";
}
$columnEl.addClass("drag-over");
this.showDropIndicator($columnEl, e);
}
});
$columnEl.on("dragleave", (e) => {
// Only remove drag-over if we're leaving the column entirely
const rect = $columnEl[0].getBoundingClientRect();
const originalEvent = e.originalEvent as DragEvent;
const x = originalEvent.clientX;
const y = originalEvent.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
$columnEl.removeClass("drag-over");
this.cleanupNoteDropIndicators($columnEl);
}
});
$columnEl.on("drop", async (e) => {
if (this.context.draggedNote && !this.context.draggedColumn) {
e.preventDefault();
$columnEl.removeClass("drag-over");
if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
await this.handleNoteDrop($columnEl, column);
}
}
});
}
cleanup() {
this.cleanupAllDropIndicators();
this.$container.find('.board-column').removeClass('drag-over');
}
private setupMouseDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
$noteEl.on("dragstart", (e) => {
this.context.draggedNote = note;
this.context.draggedBranch = branch;
this.context.draggedNoteElement = $noteEl;
$noteEl.addClass("dragging");
// Set drag data
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.effectAllowed = "move";
originalEvent.dataTransfer.setData("text/plain", note.noteId);
}
});
$noteEl.on("dragend", () => {
$noteEl.removeClass("dragging");
this.context.draggedNote = null;
this.context.draggedBranch = null;
this.context.draggedNoteElement = null;
// Clean up all drop indicators properly
this.cleanupAllDropIndicators();
});
}
private setupTouchDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
let isDragging = false;
let startY = 0;
let startX = 0;
let dragThreshold = 10; // Minimum distance to start dragging
let $dragPreview: JQuery<HTMLElement> | null = null;
$noteEl.on("touchstart", (e) => {
const touch = (e.originalEvent as TouchEvent).touches[0];
startX = touch.clientX;
startY = touch.clientY;
isDragging = false;
$dragPreview = null;
});
$noteEl.on("touchmove", (e) => {
e.preventDefault(); // Prevent scrolling
const touch = (e.originalEvent as TouchEvent).touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
// Start dragging if we've moved beyond threshold
if (!isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) {
isDragging = true;
this.context.draggedNote = note;
this.context.draggedBranch = branch;
this.context.draggedNoteElement = $noteEl;
$noteEl.addClass("dragging");
// Create drag preview
$dragPreview = this.createDragPreview($noteEl, touch.clientX, touch.clientY);
}
if (isDragging && $dragPreview) {
// Update drag preview position
$dragPreview.css({
left: touch.clientX - ($dragPreview.outerWidth() || 0) / 2,
top: touch.clientY - ($dragPreview.outerHeight() || 0) / 2
});
// Find element under touch point
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
if (elementBelow) {
const $columnEl = $(elementBelow).closest('.board-column');
if ($columnEl.length > 0) {
// Remove drag-over from all columns
this.$container.find('.board-column').removeClass('drag-over');
$columnEl.addClass('drag-over');
// Show drop indicator
this.showDropIndicatorAtPoint($columnEl, touch.clientY);
} else {
// Remove all drag indicators if not over a column
this.$container.find('.board-column').removeClass('drag-over');
this.cleanupAllDropIndicators();
}
}
}
});
$noteEl.on("touchend", async (e) => {
if (isDragging) {
const touch = (e.originalEvent as TouchEvent).changedTouches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
if (elementBelow) {
const $columnEl = $(elementBelow).closest('.board-column');
if ($columnEl.length > 0) {
const column = $columnEl.attr('data-column');
if (column && this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
await this.handleNoteDrop($columnEl, column);
}
}
}
// Clean up
$noteEl.removeClass("dragging");
this.context.draggedNote = null;
this.context.draggedBranch = null;
this.context.draggedNoteElement = null;
this.$container.find('.board-column').removeClass('drag-over');
this.cleanupAllDropIndicators();
// Remove drag preview
if ($dragPreview) {
$dragPreview.remove();
$dragPreview = null;
}
}
isDragging = false;
});
}
private createDragPreview($noteEl: JQuery<HTMLElement>, x: number, y: number): JQuery<HTMLElement> {
// Clone the note element for the preview
const $preview = $noteEl.clone();
$preview
.addClass('board-drag-preview')
.css({
position: 'fixed',
left: x - ($noteEl.outerWidth() || 0) / 2,
top: y - ($noteEl.outerHeight() || 0) / 2,
pointerEvents: 'none',
zIndex: 10000
})
.appendTo('body');
return $preview;
}
private showDropIndicator($columnEl: JQuery<HTMLElement>, e: JQuery.DragOverEvent) {
const originalEvent = e.originalEvent as DragEvent;
const mouseY = originalEvent.clientY;
this.showDropIndicatorAtY($columnEl, mouseY);
}
private showDropIndicatorAtPoint($columnEl: JQuery<HTMLElement>, touchY: number) {
this.showDropIndicatorAtY($columnEl, touchY);
}
private showDropIndicatorAtY($columnEl: JQuery<HTMLElement>, y: number) {
const columnRect = $columnEl[0].getBoundingClientRect();
const relativeY = y - columnRect.top;
// Clean up any existing drop indicators in this column first
this.cleanupNoteDropIndicators($columnEl);
// Create a new drop indicator
const $dropIndicator = $("<div>").addClass("board-drop-indicator");
// Find the best position to insert the note
const $notes = this.context.draggedNoteElement ?
$columnEl.find(".board-note").not(this.context.draggedNoteElement) :
$columnEl.find(".board-note");
let insertAfterElement: HTMLElement | null = null;
$notes.each((_, noteEl) => {
const noteRect = noteEl.getBoundingClientRect();
const noteMiddle = noteRect.top + noteRect.height / 2 - columnRect.top;
if (relativeY > noteMiddle) {
insertAfterElement = noteEl;
}
});
// Position the drop indicator
if (insertAfterElement) {
$(insertAfterElement).after($dropIndicator);
} else {
// Insert at the beginning (after the header)
const $header = $columnEl.find("h3");
$header.after($dropIndicator);
}
$dropIndicator.addClass("show");
}
private async handleNoteDrop($columnEl: JQuery<HTMLElement>, column: string) {
const draggedNoteElement = this.context.draggedNoteElement;
const draggedNote = this.context.draggedNote;
const draggedBranch = this.context.draggedBranch;
if (draggedNote && draggedNoteElement && draggedBranch) {
const currentColumn = draggedNoteElement.attr("data-current-column");
// Capture drop indicator position BEFORE removing it
const dropIndicator = $columnEl.find(".board-drop-indicator.show");
let targetBranchId: string | null = null;
let moveType: "before" | "after" | null = null;
if (dropIndicator.length > 0) {
// Find the note element that the drop indicator is positioned relative to
const nextNote = dropIndicator.next(".board-note");
const prevNote = dropIndicator.prev(".board-note");
if (nextNote.length > 0) {
targetBranchId = nextNote.attr("data-branch-id") || null;
moveType = "before";
} else if (prevNote.length > 0) {
targetBranchId = prevNote.attr("data-branch-id") || null;
moveType = "after";
}
}
try {
// Handle column change
if (currentColumn !== column) {
await this.api.changeColumn(draggedNote.noteId, column);
}
// Handle position change (works for both same column and different column moves)
if (targetBranchId && moveType) {
if (moveType === "before") {
await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId);
} else if (moveType === "after") {
await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId);
}
}
// Update the data attributes
draggedNoteElement.attr("data-current-column", column);
} catch (error) {
console.error("Failed to update note position:", error);
} finally {
// Always clean up drop indicators after drop operation
this.cleanupAllDropIndicators();
}
}
}
private cleanupAllDropIndicators() {
// Remove all drop indicators from the DOM to prevent layout issues
this.$container.find(".board-drop-indicator").remove();
}
private cleanupNoteDropIndicators($columnEl: JQuery<HTMLElement>) {
// Remove note drop indicators from a specific column
$columnEl.find(".board-drop-indicator").remove();
}
}

View File

@@ -113,7 +113,6 @@ export default class CalendarView extends ViewMode<{}> {
private $root: JQuery<HTMLElement>;
private $calendarContainer: JQuery<HTMLElement>;
private noteIds: string[];
private calendar?: Calendar;
private isCalendarRoot: boolean;
private lastView?: string;
@@ -124,15 +123,10 @@ export default class CalendarView extends ViewMode<{}> {
this.$root = $(TPL);
this.$calendarContainer = this.$root.find(".calendar-container");
this.noteIds = args.noteIds;
this.isCalendarRoot = false;
args.$parent.append(this.$root);
}
get isFullHeight(): boolean {
return true;
}
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot");
const isEditable = !this.isCalendarRoot;
@@ -396,7 +390,7 @@ export default class CalendarView extends ViewMode<{}> {
}
}
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
// Refresh note IDs if they got changed.
if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) {
this.noteIds = this.parentNote.getChildNoteIds();
@@ -407,9 +401,14 @@ export default class CalendarView extends ViewMode<{}> {
return true;
}
// Refresh on note title change.
if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) {
this.calendar?.refetchEvents();
}
// Refresh dataset on subnote change.
if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
this.calendar.refetchEvents();
if (loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
this.calendar?.refetchEvents();
}
}
@@ -438,7 +437,7 @@ export default class CalendarView extends ViewMode<{}> {
events.push(await CalendarView.buildEvent(dateNote, { startDate }));
if (dateNote.hasChildren()) {
const childNoteIds = dateNote.getChildNoteIds();
const childNoteIds = await dateNote.getSubtreeNoteIds();
for (const childNoteId of childNoteIds) {
childNoteToDateMapping[childNoteId] = startDate;
}
@@ -464,13 +463,6 @@ export default class CalendarView extends ViewMode<{}> {
for (const note of notes) {
const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate");
if (note.hasChildren()) {
const childrenEventData = await this.buildEvents(note.getChildNoteIds());
if (childrenEventData.length > 0) {
events.push(childrenEventData);
}
}
if (!startDate) {
continue;
}

View File

@@ -1,6 +1,6 @@
import ViewMode, { ViewModeArgs } from "../view_mode.js";
import L from "leaflet";
import type { GPX, LatLng, LeafletMouseEvent, Map, Marker } from "leaflet";
import type { GPX, LatLng, Layer, LeafletMouseEvent, Map, Marker } from "leaflet";
import "leaflet/dist/leaflet.css";
import SpacedUpdate from "../../../services/spaced_update.js";
import { t } from "../../../services/i18n.js";
@@ -10,6 +10,8 @@ import toast from "../../../services/toast.js";
import { CommandListenerData, EventData } from "../../../components/app_context.js";
import { createNewNote, moveMarker, setupDragging } from "./editing.js";
import { openMapContextMenu } from "./context_menu.js";
import attributes from "../../../services/attributes.js";
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js";
const TPL = /*html*/`
<div class="geo-view">
@@ -83,6 +85,11 @@ const TPL = /*html*/`
white-space: no-wrap;
overflow: hidden;
}
.geo-map-container.dark .leaflet-div-icon .title-label {
color: white;
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black;
}
</style>
<div class="geo-map-container"></div>
@@ -138,10 +145,32 @@ export default class GeoView extends ViewMode<MapData> {
const map = L.map(this.$container[0], {
worldCopyJump: true
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
detectRetina: true
}).addTo(map);
const layerName = this.parentNote.getLabelValue("map:style") ?? DEFAULT_MAP_LAYER_NAME;
let layer: Layer;
const layerData = MAP_LAYERS[layerName];
if (layerData.type === "vector") {
const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style());
await import("@maplibre/maplibre-gl-leaflet");
layer = L.maplibreGL({
style: style as any
});
} else {
layer = L.tileLayer(layerData.url, {
attribution: layerData.attribution,
detectRetina: true
});
}
if (this.parentNote.hasLabel("map:scale")) {
L.control.scale().addTo(map);
}
this.$container.toggleClass("dark", !!layerData.isDarkTheme);
layer.addTo(map);
this.map = map;
@@ -226,7 +255,7 @@ export default class GeoView extends ViewMode<MapData> {
// Add the new markers.
this.currentMarkerData = {};
const notes = await this.parentNote.getChildNotes();
const notes = await this.parentNote.getSubtreeNotes();
const draggable = !this.isReadOnly;
for (const childNote of notes) {
if (childNote.mime === "application/gpx+xml") {
@@ -243,10 +272,6 @@ export default class GeoView extends ViewMode<MapData> {
}
}
get isFullHeight(): boolean {
return true;
}
#changeState(newState: State) {
this._state = newState;
this.$container.toggleClass("placing-note", newState === State.NewNote);
@@ -255,7 +280,7 @@ export default class GeoView extends ViewMode<MapData> {
}
}
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void {
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
// If any of the children branches are altered.
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) {
this.#reloadMarkers();
@@ -265,9 +290,14 @@ export default class GeoView extends ViewMode<MapData> {
// If any of note has its location attribute changed.
// TODO: Should probably filter by parent here as well.
const attributeRows = loadResults.getAttributeRows();
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color", "iconClass"].includes(at.name ?? ""))) {
this.#reloadMarkers();
}
// Full reload if map layer is changed.
if (loadResults.getAttributeRows().some(attr => (attr.name?.startsWith("map:") && attributes.isAffecting(attr, this.parentNote)))) {
return true;
}
}
async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) {
@@ -334,3 +364,4 @@ export default class GeoView extends ViewMode<MapData> {
}
}

View File

@@ -0,0 +1,53 @@
export interface MapLayer {
name: string;
isDarkTheme?: boolean;
}
interface VectorLayer extends MapLayer {
type: "vector";
style: string | (() => Promise<{}>)
}
interface RasterLayer extends MapLayer {
type: "raster";
url: string;
attribution: string;
}
export const MAP_LAYERS: Record<string, VectorLayer | RasterLayer> = {
"openstreetmap": {
name: "OpenStreetMap",
type: "raster",
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
},
"versatiles-colorful": {
name: "VersaTiles Colorful",
type: "vector",
style: async () => (await import("./styles/colorful/en.json")).default
},
"versatiles-eclipse": {
name: "VersaTiles Eclipse",
type: "vector",
style: async () => (await import("./styles/eclipse/en.json")).default,
isDarkTheme: true
},
"versatiles-graybeard": {
name: "VersaTiles Graybeard",
type: "vector",
style: async () => (await import("./styles/graybeard/en.json")).default
},
"versatiles-neutrino": {
name: "VersaTiles Neutrino",
type: "vector",
style: async () => (await import("./styles/neutrino/en.json")).default
},
"versatiles-shadow": {
name: "VersaTiles Shadow",
type: "vector",
style: async () => (await import("./styles/shadow/en.json")).default,
isDarkTheme: true
}
};
export const DEFAULT_MAP_LAYER_NAME: keyof typeof MAP_LAYERS = "versatiles-colorful";

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More