Compare commits

..

270 Commits

Author SHA1 Message Date
Elian Doran
54c8322960 chore(react/promoted_attributes): multiplicity 2025-11-13 08:17:32 +02:00
Elian Doran
3d0d1fa36e chore(react/promoted_attributes): basic structures 2025-11-12 10:21:28 +02:00
Elian Doran
3f93d191b3 Translations update from Hosted Weblate (#7701) 2025-11-12 08:27:40 +02:00
green
3a83e7f632 Translated using Weblate (Japanese)
Currently translated at 100.0% (389 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-11-12 07:18:19 +01:00
green
2acede95d7 Translated using Weblate (Japanese)
Currently translated at 100.0% (1623 of 1623 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-11-12 07:18:18 +01:00
Unknown
052a60ba58 Translated using Weblate (Spanish)
Currently translated at 100.0% (389 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/es/
2025-11-12 07:18:17 +01:00
Unknown
a633c6eecd Translated using Weblate (Spanish)
Currently translated at 100.0% (1623 of 1623 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/es/
2025-11-12 07:18:16 +01:00
Elian Doran
b6561e6375 "Open note on server" menu item (#7477) 2025-11-12 08:17:53 +02:00
Elian Doran
7af9df2ee3 chore(deps): update dependency @redocly/cli to v2.11.1 (#7695) 2025-11-12 08:08:29 +02:00
Elian Doran
3c4b7fd490 chore(deps): update dependency @smithy/middleware-retry to v4.4.7 (#7696) 2025-11-12 08:08:15 +02:00
Elian Doran
f5ed4007e3 chore(deps): update typescript-eslint monorepo to v8.46.4 (#7698) 2025-11-12 08:07:55 +02:00
Elian Doran
f26469bdc2 fix(deps): update dependency i18next to v25.6.2 (#7699) 2025-11-12 08:07:38 +02:00
Elian Doran
3326d803a0 Translations update from Hosted Weblate (#7700) 2025-11-12 08:07:15 +02:00
Hosted Weblate
b60a28c52c Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2025-11-12 05:55:12 +00:00
Elian Doran
139c60ac73 Hotfix (#7679) 2025-11-12 07:54:54 +02:00
renovate[bot]
c7ad6131cb fix(deps): update dependency i18next to v25.6.2 2025-11-12 02:54:22 +00:00
renovate[bot]
86e2a762a4 chore(deps): update typescript-eslint monorepo to v8.46.4 2025-11-12 02:53:34 +00:00
renovate[bot]
bf9f880a21 chore(deps): update dependency @smithy/middleware-retry to v4.4.7 2025-11-12 02:51:14 +00:00
renovate[bot]
5491aaab85 chore(deps): update dependency @redocly/cli to v2.11.1 2025-11-12 02:50:29 +00:00
Elian Doran
40568ac547 Translations update from Hosted Weblate (#7689) 2025-11-11 13:56:40 +02:00
hulmgulm
0039e5e60c Translated using Weblate (German)
Currently translated at 21.7% (33 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/de/
2025-11-11 11:51:35 +01:00
Ondřej Soldát
813e2e8c9b Translated using Weblate (Czech)
Currently translated at 1.9% (3 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/cs/
2025-11-11 11:51:34 +01:00
Dong-ha, Lee
f186e929b2 Translated using Weblate (Korean)
Currently translated at 10.5% (41 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ko/
2025-11-11 11:51:25 +01:00
Giovi
31d2abf954 Translated using Weblate (Italian)
Currently translated at 100.0% (389 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/it/
2025-11-11 11:51:23 +01:00
Giovi
bd69280735 Translated using Weblate (Italian)
Currently translated at 100.0% (1623 of 1623 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/it/
2025-11-11 11:51:21 +01:00
Elian Doran
b86c656895 Note info ribbon flex layout (#7678) 2025-11-10 22:18:05 +02:00
Elian Doran
bc32fe749d chore(deps): update pnpm to v10.21.0 (#7672) 2025-11-10 22:12:20 +02:00
Elian Doran
5970a242c9 chore(deps): update dependency esbuild to v0.27.0 (#7671) 2025-11-10 22:11:36 +02:00
Elian Doran
4fc9848a3b Translations update from Hosted Weblate (#7682) 2025-11-10 20:36:02 +02:00
Hosted Weblate
b8de5b3348 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2025-11-10 18:29:41 +00:00
Elian Doran
5a01f75d67 edited notes: better sql like statement (#7681) 2025-11-10 20:29:25 +02:00
contributor
d0a994c102 fix sql like pattern 2025-11-10 20:15:08 +02:00
contributor
e883f32f89 edited notes: better sql like statement 2025-11-10 20:04:50 +02:00
Elian Doran
a6fc54cb81 fix(mindmap): switching between read-only and editable mode 2025-11-10 19:59:01 +02:00
Elian Doran
76bd6a5ab9 refactor(mindmap): unnecessary content logic 2025-11-10 19:44:45 +02:00
Elian Doran
3b5d749d86 docs(dev): branching strategy 2025-11-10 19:31:10 +02:00
renovate[bot]
6761f741ca chore(deps): update dependency esbuild to v0.27.0 2025-11-10 13:48:16 +00:00
contributor
a1c959aabd use class instead of inline style for note info calculate button 2025-11-10 13:18:07 +02:00
contributor
4d1ebd011c ribbon Note Info in flex layout instead of table 2025-11-10 13:18:07 +02:00
renovate[bot]
2947967b79 chore(deps): update pnpm to v10.21.0 2025-11-10 02:58:46 +00:00
Adorian Doran
52691b9e88 style/empty note: fix alignment 2025-11-10 03:05:05 +02:00
Adorian Doran
daba806e12 close #7668 2025-11-10 02:09:14 +02:00
Adorian Doran
d7f7049b5d fix #7667 2025-11-10 02:01:45 +02:00
contributor
e811db3651 only show "Open note on server" for electron app 2025-11-10 00:26:28 +02:00
contributor
a1d38b6bb8 add 'Open note on server' menu item to NoteActions drop-down 2025-11-10 00:26:28 +02:00
Elian Doran
c781eab061 Translations update from Hosted Weblate (#7664) 2025-11-09 22:53:15 +02:00
Elian Doran
fb91d4fbd4 docs(user): mention updates after react improvements 2025-11-09 22:52:02 +02:00
Sir-Ivysaur
cd6fcbd283 Translated using Weblate (Indonesian)
Currently translated at 9.8% (15 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/id/
2025-11-09 20:32:53 +00:00
Elian Doran
0ccc350ddf Port type widgets to React (#7044) 2025-11-09 22:32:35 +02:00
Elian Doran
efa7fd0b7d chore(client): use backward-compatible hiding mechanism 2025-11-09 22:20:49 +02:00
Elian Doran
5f4d0325aa Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-11-09 21:37:56 +02:00
Elian Doran
763fa0b515 fix(client/type_widgets): empty tab not working when closing last tab 2025-11-09 20:41:29 +02:00
Elian Doran
7ba91b7a9d chore(desktop): fix import 2025-11-09 19:36:58 +02:00
Elian Doran
7fc9f08843 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-11-09 19:30:46 +02:00
Elian Doran
e28794d706 chore(edit-docs): fix typecheck 2025-11-09 19:28:08 +02:00
Elian Doran
ae1c8f0a0b chore(client): review from Copilot 2025-11-09 15:13:57 +02:00
Elian Doran
1257e46852 chore(client): review from Gemini 2025-11-09 15:10:40 +02:00
Elian Doran
40a7f286a3 fix(client): typecheck issues 2025-11-09 15:04:29 +02:00
Elian Doran
652cccadd7 fix(type_widgets/code): read-only bar not visible for dark themes 2025-11-09 14:59:47 +02:00
Elian Doran
308fd00508 fix(type_widgets/code): not reacting to language changes 2025-11-09 14:48:31 +02:00
Elian Doran
35f413505c fix(type_widgets/code): background color leaking in SQLite 2025-11-09 14:40:14 +02:00
Elian Doran
a82d15e83d fix(type_widgets/attachments): attachments not refreshing when uploading new revision 2025-11-09 14:26:32 +02:00
Elian Doran
24b169d667 fix(type_widgets): "empty" note type displayed on refresh 2025-11-09 13:52:07 +02:00
Elian Doran
b36ef54507 fix(type_widgets): code background leaking when switching types 2025-11-09 13:37:31 +02:00
Elian Doran
c772430dd0 fix(type_widgets): switching between note types shows wrong content 2025-11-09 13:24:12 +02:00
Elian Doran
d4194c503c fix(type_widgets/relation_map): map emptied when switching fast 2025-11-09 12:38:12 +02:00
Elian Doran
33a41d2f86 Merge remote-tracking branch 'origin/main' into react/type_widgets
; Conflicts:
;	apps/client/src/layouts/desktop_layout.tsx
;	apps/client/src/layouts/mobile_layout.tsx
;	apps/client/src/widgets/note_detail.ts
;	apps/client/src/widgets/react/hooks.tsx
2025-11-09 12:08:50 +02:00
Elian Doran
319e28387f refactor(react/type_widgets): separate persistence from canvas 2025-11-06 15:44:09 +02:00
Elian Doran
0b740bb007 chore(react/type_widgets): remove logging 2025-11-06 12:05:38 +02:00
Elian Doran
bb7fa9a2e6 fix(react/type_widgets): unable to switch to notes of same note type 2025-11-06 12:02:48 +02:00
Elian Doran
5e83e6fa34 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-11-06 11:40:04 +02:00
Elian Doran
8828e36624 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-10-26 20:19:47 +02:00
Elian Doran
bca0846565 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-10-10 23:05:45 +03:00
Elian Doran
8fef28dcc7 chore(docs): revert changes to documentation 2025-10-10 22:48:20 +03:00
Elian Doran
040ffe945a Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-10-10 22:46:53 +03:00
Elian Doran
4997543fc7 doc(guide): interaction on relation map, including tree operation 2025-10-05 20:19:18 +03:00
Elian Doran
63c91b6741 Revert "fix(deps): downgrade mind-elixir (closes #7170)"
This reverts commit 5969815ed1.
2025-10-05 20:00:08 +03:00
Elian Doran
27b6e26fa5 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-10-05 19:59:46 +03:00
Elian Doran
7930745a01 chore(react/type_widgets): fix type errors 2025-10-05 19:55:25 +03:00
Elian Doran
6ffe8a2eb5 chore(react/type_widgets): save on window closing 2025-10-05 18:12:36 +03:00
Elian Doran
0dcaa8719f chore(react/type_widgets): save code note if needed 2025-10-05 17:56:31 +03:00
Elian Doran
608605af12 chore(react/type_widgets): save if needed 2025-10-05 17:53:19 +03:00
Elian Doran
3f7b8447d0 refactor(react/type_widgets): bring back executeInActiveNoteDetailWidget 2025-10-05 17:34:45 +03:00
Elian Doran
d3594e4a05 refactor(react/type_widgets): bring back printing and exporting to PDF 2025-10-05 17:07:34 +03:00
Elian Doran
156b4101a5 refactor(react/type_widgets): bring back cut to note 2025-10-05 17:05:05 +03:00
Elian Doran
73213d2a17 refactor(react/type_widgets): bring back execute with type widget 2025-10-05 16:59:46 +03:00
Elian Doran
763bcbd394 refactor(react/type_widgets): extract full height to note types 2025-10-05 16:45:26 +03:00
Elian Doran
d90043e586 chore(react/type_widgets): bring back launch bar fixed effect 2025-10-05 16:42:58 +03:00
Elian Doran
c209a699ea refactor(react/type_widgets): deduplicate containers 2025-10-05 16:36:59 +03:00
Elian Doran
22069d0aef refactor(react/type_widgets): extract note types to different file 2025-10-05 15:55:25 +03:00
Elian Doran
3248654820 chore(react/type_widgets): cache note types 2025-10-05 15:47:27 +03:00
Elian Doran
269c7c9ce7 feat(ai_chat): allow viewing source of the ai chat 2025-10-05 14:25:08 +03:00
Elian Doran
b0c984decd chore(react/type_widgets): fix refresh on first start 2025-10-05 14:15:51 +03:00
Elian Doran
cebb54ddf6 chore(react/type_widgets): get LLM note to load 2025-10-05 12:40:55 +03:00
Elian Doran
22f8929da6 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-10-05 12:14:14 +03:00
Elian Doran
7192d40e80 chore(react/type_widgets): get ai chat widget to render 2025-10-05 10:21:52 +03:00
Elian Doran
df9d481a93 refactor(mindmap): use proper way to detect direction
See https://github.com/SSShooter/mind-elixir-core/issues/150.
2025-10-04 19:17:13 +03:00
Elian Doran
cf37549f19 refactor(notemap): use proper types 2025-10-04 18:55:26 +03:00
Elian Doran
d2dda95654 fix(notemap): invisible hover color on light theme 2025-10-04 18:28:03 +03:00
Elian Doran
0770f97010 Merge remote-tracking branch 'origin/main' into react/type_widgets
; Conflicts:
;	apps/client/src/widgets/type_widgets/read_only_code.ts
2025-10-04 18:18:11 +03:00
Elian Doran
3caaf2ab79 chore(react/type_widgets): port note map type widget 2025-10-04 18:16:45 +03:00
Elian Doran
8f819a7786 chore(react): reintroduce reactivity to mapRootIdLabel 2025-10-04 18:09:40 +03:00
Elian Doran
0da66617a8 chore(react): reintroduce link distance 2025-10-04 14:14:02 +03:00
Elian Doran
5efe05490d chore(react): reintroduce force configuration 2025-10-04 13:58:29 +03:00
Elian Doran
656b234740 chore(react): reintroduce centering i note map 2025-10-04 13:55:22 +03:00
Elian Doran
e6e9cd3f35 chore(react): improve style of buttons 2025-10-04 13:44:11 +03:00
Elian Doran
845c76fc42 chore(react): bring back fixing nodes 2025-10-04 13:37:36 +03:00
Elian Doran
a4d6da72a1 chore(react): bring back interaction with nodes 2025-10-04 13:17:57 +03:00
Elian Doran
35438d2599 refactor(react): integrate get color for node in rendering 2025-10-04 13:15:13 +03:00
Elian Doran
9a1e7ca3ae chore(react): bring back node pointer & link configuration 2025-10-04 13:10:18 +03:00
Elian Doran
2d29d1b41f chore(react): add back note map link configuration 2025-10-04 13:04:40 +03:00
Elian Doran
ad5ff6e41a refactor(react): use component for map type switcher 2025-10-04 12:58:24 +03:00
Elian Doran
20dcbff68f chore(react): reintroduce map type toggles 2025-10-04 12:55:08 +03:00
Elian Doran
c127e19cfa chore(react): fix obtaining of CSS data for note map 2025-10-04 12:31:35 +03:00
Elian Doran
e32237559e chore(react): start rendering nodes in note map 2025-10-04 12:29:48 +03:00
Elian Doran
09811d23f6 chore(react): port data part of server API 2025-10-04 11:40:31 +03:00
Elian Doran
b41042fec4 chore(react):start porting note map 2025-10-04 11:07:16 +03:00
Elian Doran
08fae19d19 fix(react/type_widgets): crash when switching between relation maps 2025-10-04 10:37:28 +03:00
Elian Doran
9cceff4f02 chore(react/type_widgets): finalize porting relation map 2025-10-04 10:32:23 +03:00
Elian Doran
67d9154795 chore(react/type_widgets): reintroduce relation note dragging 2025-10-04 10:15:38 +03:00
Elian Doran
1eca9f6541 chore(react/type_widgets): reintroduce relation context menu 2025-10-04 09:54:47 +03:00
Elian Doran
c469fffb6e chore(react/type_widgets): reintroduce relation creation 2025-10-04 09:37:14 +03:00
Elian Doran
d076d54170 refactor(react/type_widgets): extract context menu to separate file 2025-10-04 09:15:15 +03:00
Elian Doran
3256c14a20 chore(react/type_widgets): accidental double source config 2025-10-04 09:06:24 +03:00
Elian Doran
460e01a2d6 refactor(react/type_widgets): split note box into separate file 2025-10-04 09:04:22 +03:00
Elian Doran
1913355069 chore(react/type_widgets): relation map source/target config 2025-10-04 08:58:06 +03:00
Elian Doran
f687d91201 chore(react/type_widgets): bring back dragging notes in relation map 2025-10-03 22:40:22 +03:00
Elian Doran
e8e93e985d fix(react/type_widgets): relation map getting occassionally wiped 2025-10-03 13:16:20 +03:00
Elian Doran
c5c304f85b chore(react/type_widgets): add dragigng logic for jsplumb 2025-10-03 11:58:00 +03:00
Elian Doran
58aea03114 fix(react/type_widgets): unable to add new items on existing map 2025-10-03 11:38:05 +03:00
Elian Doran
4af842d2f2 refactor(react/type_widgets): use dedicated component for items 2025-10-03 11:02:33 +03:00
Elian Doran
3b2f5bb09d refactor(react/type_widgets): extract JSPlumb into a separate file 2025-10-03 10:17:45 +03:00
Elian Doran
2d67aab288 fix(react/type_widgets): unable to add new items if the map is empty 2025-10-03 10:11:21 +03:00
Elian Doran
838d761b50 Merge remote-tracking branch 'origin/main' into react/type_widgets
; Conflicts:
;	apps/client/src/widgets/react/hooks.tsx
;	apps/client/src/widgets/type_widgets/abstract_text_type_widget.ts
2025-10-03 09:28:17 +03:00
Elian Doran
7a2d91e7de chore(type_widgets): get relations to render 2025-09-29 22:31:53 +03:00
Elian Doran
082ea7b5c1 chore(type_widgets): port relation map overlays 2025-09-29 21:35:44 +03:00
Elian Doran
c58414bbc1 chore(type_widgets): relation map rename title 2025-09-29 21:11:40 +03:00
Elian Doran
1c1243912b refactor(type_widgets): use API architecture for relation map 2025-09-29 21:05:29 +03:00
Elian Doran
614fc66890 refactor(type_widgets): move relation map to dedicated folder 2025-09-29 20:34:30 +03:00
Elian Doran
0937ef72e2 chore(type_widgets): start porting context menu 2025-09-29 20:33:15 +03:00
Elian Doran
3571023685 chore(type_widgets): bring back relation map zoom controls 2025-09-29 20:16:45 +03:00
Elian Doran
2cd3e3f9c8 chore(type_widgets): bring back relation map note creation 2025-09-29 20:06:22 +03:00
Elian Doran
3d08f686cf feat(code): pretty-print JSON in view source 2025-09-29 19:47:56 +03:00
Elian Doran
d2bf972305 chore(type_widgets): save pan & zoom 2025-09-29 19:41:08 +03:00
Elian Doran
39bd236799 refactor(type_widgets): use editorspaced update for data 2025-09-29 19:32:50 +03:00
Elian Doran
d8b9d14712 chore(type_widgets): introduce panzoom 2025-09-29 19:28:46 +03:00
Elian Doran
9d4127ba6d chore(type_widgets): render note box 2025-09-29 19:04:00 +03:00
Elian Doran
04b678ef4c chore(type_widgets): start porting relation map 2025-09-29 18:50:40 +03:00
Elian Doran
286d7c8228 Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-09-29 17:53:16 +03:00
Elian Doran
5547c3fc2b feat(canvas): read-only mode 2025-09-25 19:28:03 +03:00
Elian Doran
4381399978 feat(mindmap): read-only mode 2025-09-25 19:12:37 +03:00
Elian Doran
5bfa0d13e3 chore(react/type_widgets): refresh on all viewscope changes 2025-09-25 18:44:06 +03:00
Elian Doran
5c21759de9 fix(react/type_widgets): unable to switch view-mode for same note 2025-09-25 18:39:47 +03:00
Elian Doran
e2ef58ed50 chore(react/type_widgets): finalize porting 2025-09-25 18:26:52 +03:00
Elian Doran
7af610a5b4 chore(react/type_widgets): set up image opening 2025-09-25 18:22:58 +03:00
Elian Doran
8a442ba492 chore(react/type_widgets): port executeWithTextEditor 2025-09-25 18:07:47 +03:00
Elian Doran
3ed399a888 chore(react/type_widgets): port text touchbar (untested) 2025-09-25 17:53:48 +03:00
Elian Doran
37d33fb975 chore(react/type_widgets): port @-mention note creation 2025-09-25 17:21:03 +03:00
Elian Doran
d443d79685 chore(react/type_widgets): port and fix follow link under cursor 2025-09-25 14:35:52 +03:00
Elian Doran
a975576214 chore(react/type_widgets): react to snippet changes 2025-09-25 14:21:12 +03:00
Elian Doran
3673162a48 chore(react/type_widgets): bring back add include to note 2025-09-25 13:53:11 +03:00
Elian Doran
0ac428b57a chore(react/type_widgets): remove already integrated file 2025-09-25 13:26:50 +03:00
Elian Doran
45bd9b72b9 chore(react/type_widgets): set up code block word wrap 2025-09-25 13:24:43 +03:00
Elian Doran
cc6ac7d1da chore(react/type_widgets): port text link insertion mechanism 2025-09-25 12:07:06 +03:00
Elian Doran
232fe4e63a refactor(react/type_widgets): move mobile_editor_toolbar 2025-09-25 11:17:55 +03:00
Elian Doran
597426f10d Merge remote-tracking branch 'origin/main' into react/type_widgets 2025-09-25 11:12:28 +03:00
Elian Doran
a0a904766f fix(options/mfa): significant calls to OAuth status endpoint 2025-09-25 10:29:09 +03:00
Elian Doran
db46ca0a76 chore(react/type_widget): insert date/time to text 2025-09-22 18:03:19 +03:00
Elian Doran
a26ee0d769 chore(react/type_widget): hot-pluggable keyboard shortcuts 2025-09-22 17:52:05 +03:00
Elian Doran
46db047fa0 chore(react/type_widget): scroll to end & focus 2025-09-22 13:36:18 +03:00
Elian Doran
efaa1815ec chore(react/type_widget): classic editor & inspector 2025-09-22 13:19:20 +03:00
Elian Doran
2eab8b92d5 chore(react/type_widget): react to content language changes 2025-09-22 12:49:03 +03:00
Elian Doran
8a185262fb chore(react/type_widget): refactor event handling slightly 2025-09-22 12:43:43 +03:00
Elian Doran
f6631b7b9a chore(react/type_widget): save on change 2025-09-22 12:41:32 +03:00
Elian Doran
1e323de01b chore(react/type_widget): port watchdog state change 2025-09-22 12:13:31 +03:00
Elian Doran
f00f2ee5e4 chore(react/type_widget): port notification warning 2025-09-22 12:07:44 +03:00
Elian Doran
78b83cd17b chore(react/type_widget): get editable text to show up 2025-09-22 12:02:45 +03:00
Elian Doran
adea3abff4 chore(react/type_widget): add missing interface 2025-09-22 10:52:03 +03:00
Elian Doran
206618fd54 style(next): improve code block hiehgt in note list 2025-09-22 10:45:55 +03:00
Elian Doran
58a6d70cbb chore(react/type_widget): finalize porting canvas 2025-09-22 10:40:57 +03:00
Elian Doran
44b92a024c chore(react/type_widget): set up self-hosted fonts 2025-09-22 10:14:24 +03:00
Elian Doran
68bf5b7e68 chore(react/type_widget): set up canvas persistence 2025-09-22 09:22:09 +03:00
Elian Doran
8c85aa343c chore(react/type_widget): add more options to canvas 2025-09-22 08:40:56 +03:00
Elian Doran
592a8b2232 chore(react/type_widgets): start porting canvas 2025-09-21 23:33:38 +03:00
Elian Doran
e1ac319a7b chore(react/type_widgets): active note not refreshing 2025-09-21 22:59:16 +03:00
Elian Doran
763c489cd3 feat(render): integrate with search 2025-09-21 22:58:58 +03:00
Elian Doran
b990770e48 feat(render): add a floating button to refresh 2025-09-21 22:44:39 +03:00
Elian Doran
344607d437 chore(react/type_widgets): get render to work 2025-09-21 22:33:11 +03:00
Elian Doran
70d0a5441a chore(react/type_widget): port render note partially 2025-09-21 22:24:51 +03:00
Elian Doran
61278e1f5a chore(react/type_widget): use different loading mechanism 2025-09-21 21:49:23 +03:00
Elian Doran
b73ea6ac4f chore(react/type_widget): reflect note type changes 2025-09-21 21:13:04 +03:00
Elian Doran
5d833c1ac4 chore(react/type_widget): finalize read-only text 2025-09-21 20:50:26 +03:00
Elian Doran
2947682783 chore(react/type_widget): add code block & image integration 2025-09-21 20:47:40 +03:00
Elian Doran
fb46e09428 chore(react/type_widget): render reference links 2025-09-21 20:34:02 +03:00
Elian Doran
ff941b2cb1 chore(react/type_widget): render math in read-only text 2025-09-21 20:29:38 +03:00
Elian Doran
a8007b9063 chore(react/type_widget): render included notes in read-only text 2025-09-21 20:27:58 +03:00
Elian Doran
2f3c2bbac8 chore(react/type_widget): render inline mermaid in read-only text 2025-09-21 20:15:57 +03:00
Elian Doran
e4eb96a1ae chore(react/type_widget): start porting read-only text 2025-09-21 20:03:28 +03:00
Elian Doran
ffe4e9b8de chore(react/type_widget): add deletion widget 2025-09-21 19:43:36 +03:00
Elian Doran
f2b4f49be2 chore(react/type_widget): convert attachment actions 2025-09-21 19:34:03 +03:00
Elian Doran
376ef0c679 chore(react/type_widgets): introduce disabled tooltip 2025-09-21 12:02:02 +03:00
Elian Doran
b7574b3ca7 chore(react/type_widget): start porting attachment actions 2025-09-21 11:06:20 +03:00
Elian Doran
ae1954c320 chore(react/type_widget): port attachment detail 2025-09-21 10:57:08 +03:00
Elian Doran
3171413a18 chore(react/type_widget): react to attachment changes 2025-09-21 10:40:19 +03:00
Elian Doran
dc73467d34 chore(react/type_widget): list attachments with content 2025-09-21 10:32:12 +03:00
Elian Doran
58b14ae31c fix(react/type_widget): mind map attachment incorrect 2025-09-21 10:32:01 +03:00
Elian Doran
e117fbd471 chore(react/type_widget): port atttachment list header 2025-09-21 09:57:09 +03:00
Elian Doran
9a3f675950 chore(react/type_widget): finalize mind map with export PNG/SVG 2025-09-21 09:46:58 +03:00
Elian Doran
26400f2590 fix(mindmap): search not working 2025-09-21 09:35:23 +03:00
Elian Doran
7d99a92bd9 chore(react/type_widget): save mindmap attachment 2025-09-21 09:26:44 +03:00
Elian Doran
3417e37f16 chore(react/type_widget): save direction upon button press 2025-09-21 09:20:00 +03:00
Elian Doran
143e6a556c chore(react/type_widget): persist data 2025-09-20 22:22:20 +03:00
Elian Doran
02259c55f3 chore(react/type_widget): get mindmap to render 2025-09-20 21:52:57 +03:00
Elian Doran
cc19a217ad chore(react/type_widget): finalize SVG split editor 2025-09-20 21:34:56 +03:00
Elian Doran
d95ed4a5d2 chore(react/type_widget): export as SVG/PNG 2025-09-20 21:29:20 +03:00
Elian Doran
469683f30f chore(react/type_widget): reimplement zoom + reset buttons 2025-09-20 21:23:34 +03:00
Elian Doran
42d0cc12b5 chore(react/type_widget): more generic repositioning 2025-09-20 21:19:19 +03:00
Elian Doran
b376842e2d chore(react/type_widget): reposition on split resize 2025-09-20 21:12:29 +03:00
Elian Doran
145ff1a2a5 chore(react/type_widget): restore pan/zoom 2025-09-20 21:07:51 +03:00
Elian Doran
8e9f5fb486 chore(react/type_widget): fix 4px scroll in SVG editor 2025-09-20 14:34:15 +03:00
Elian Doran
3dd757a857 chore(react/type_widget): disable code background change in split 2025-09-20 14:16:34 +03:00
Elian Doran
bde7b753a0 chore(react/type_widget): save SVG attachment 2025-09-20 14:08:50 +03:00
Elian Doran
02017ebd9d chore(react/type_widget): bring back on error opacity 2025-09-20 13:47:12 +03:00
Elian Doran
8caaa99415 chore(react/type_widget): basic SVG rendering 2025-09-20 13:27:58 +03:00
Elian Doran
c49b90d33f chore(react/type_widget): add preview buttons 2025-09-20 13:15:13 +03:00
Elian Doran
6dd939df14 chore(react/type_widget): bring back update interval 2025-09-20 13:04:36 +03:00
Elian Doran
b19da81572 chore(react/type_widget): force line wrapping 2025-09-20 13:00:15 +03:00
Elian Doran
425ffc02d8 chore(react/type_widget): bring back split resizer 2025-09-20 12:54:18 +03:00
Elian Doran
695e8489ad chore(react/type_widget): pass error information 2025-09-20 12:41:39 +03:00
Elian Doran
2f4e13b1bb chore(react/type_widget): bring back order of editor/preview 2025-09-20 12:38:05 +03:00
Elian Doran
c8a9b994d6 chore(react/type_widget): bring back read-only 2025-09-20 12:34:36 +03:00
Elian Doran
3d5b319eb2 chore(react/type_widget): bring back split orientation 2025-09-20 12:31:45 +03:00
Elian Doran
bed3c2dc67 chore(react/type_widget): prepare structure for split editor 2025-09-20 12:25:11 +03:00
Elian Doran
256d1863d2 chore(react/type_widget): port backend log 2025-09-20 12:16:51 +03:00
Elian Doran
4a4502dfea chore(react/type_widget): bring back read-only temporary disable 2025-09-20 11:59:43 +03:00
Elian Doran
91f21e149b chore(react/type_widget): bring back focusing after tab switch 2025-09-20 11:46:23 +03:00
Elian Doran
6ef468adc4 chore(react/type_widget): bring back scroll to end 2025-09-20 11:38:28 +03:00
Elian Doran
e576fa03da chore(react/type_widget): fix sizing 2025-09-20 11:30:40 +03:00
Elian Doran
6bcce08042 chore(react/type_widget): react to line wrapping 2025-09-20 11:25:07 +03:00
Elian Doran
f496caa92c refactor(react/type_widget): separate Trilium-specific implementation 2025-09-20 11:22:48 +03:00
Elian Doran
43dcdf8925 chore(react/type_widget): apply background color for read-only code notes 2025-09-20 11:11:00 +03:00
Elian Doran
2c014fb071 chore(react/type_widget): set up background color for code notes 2025-09-20 11:02:43 +03:00
Elian Doran
2273507ef4 chore(react/type_widget): unnecessary imports 2025-09-20 10:37:34 +03:00
Elian Doran
70a710be79 chore(react/type_widget): react to code theme 2025-09-20 10:14:21 +03:00
Elian Doran
7a3ee7971c chore(react/type_widget): add back keyboard shortcut for editable code 2025-09-20 10:08:46 +03:00
Elian Doran
c86123e3a9 chore(react/type_widget): integrate touch bar for editable code 2025-09-20 10:03:00 +03:00
Elian Doran
9480227b69 chore(react/type_widget): add more options to editable code 2025-09-20 09:59:49 +03:00
Elian Doran
79be13e6c7 chore(react/type_widget): reload content on external change 2025-09-20 09:56:55 +03:00
Elian Doran
63e3a27b34 refactor(react/type_widget): simplify handling of new notes 2025-09-20 09:47:28 +03:00
Elian Doran
9eae6620d0 chore(react/type_widget): basic editable code 2025-09-20 09:44:36 +03:00
Elian Doran
6517dd1190 chore(react/type_widget): finalize readonly code 2025-09-20 09:06:55 +03:00
Elian Doran
f72087acc3 chore(react/type_widget): port read only code basic functionality 2025-09-20 08:57:47 +03:00
Elian Doran
77e7c414b6 chore(react/type_widget): react to note revisions 2025-09-19 22:45:12 +03:00
Elian Doran
3a68395ca7 feat(react/type_widget): add copy image reference floating button to image 2025-09-19 22:42:06 +03:00
Elian Doran
0a0d9775b2 chore(react/type_widget): port image 2025-09-19 22:41:18 +03:00
Elian Doran
aa6e68ad39 chore(react/type_widget): port file 2025-09-19 22:22:45 +03:00
Elian Doran
034073a5e1 chore(react/type_widget): fix missing tbody 2025-09-19 22:18:10 +03:00
Elian Doran
d83ff641d7 chore(react/type_widget): bring back full-height 2025-09-19 21:55:37 +03:00
Elian Doran
071fcb85c9 chore(react/type_widget): basic integration of web view 2025-09-19 21:27:45 +03:00
Elian Doran
daa5ee93e9 chore(react/type_widget): port content widget 2025-09-19 21:18:09 +03:00
Elian Doran
db7cda3fe6 chore(react/type_widget): have book react to reloaded children 2025-09-19 19:06:07 +03:00
Elian Doran
fa55c5720e chore(react/type_widget): port book 2025-09-19 19:03:31 +03:00
Elian Doran
d1a9890932 chore(react/type_widget): port protected session 2025-09-19 18:55:04 +03:00
Elian Doran
c9fe358811 chore(react/type_widget): port none type widget 2025-09-19 18:35:49 +03:00
Elian Doran
bbb927c83f chore(react/type_widget): port doc widget 2025-09-19 18:32:45 +03:00
Elian Doran
07b86c8cf7 chore(react/type_widget): port empty workspace switcher 2025-09-19 18:15:10 +03:00
Elian Doran
3dbf20af52 chore(react/type_widget): port empty search 2025-09-19 18:09:23 +03:00
Elian Doran
1fb329565f chore(react/type_widget): move old widgets 2025-09-19 17:40:24 +03:00
Elian Doran
06bfb0073a chore(react/type_widget): determine note type 2025-09-19 17:31:10 +03:00
Elian Doran
3d64c320fb chore(react/type_widget): start with fresh note detail 2025-09-19 16:53:31 +03:00
209 changed files with 7534 additions and 7496 deletions

View File

@@ -9,9 +9,9 @@
"keywords": [], "keywords": [],
"author": "Elian Doran <contact@eliandoran.me>", "author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"packageManager": "pnpm@10.20.0", "packageManager": "pnpm@10.21.0",
"devDependencies": { "devDependencies": {
"@redocly/cli": "2.11.0", "@redocly/cli": "2.11.1",
"archiver": "7.0.1", "archiver": "7.0.1",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"react": "19.2.0", "react": "19.2.0",

View File

@@ -43,7 +43,7 @@
"draggabilly": "3.0.0", "draggabilly": "3.0.0",
"force-graph": "1.51.0", "force-graph": "1.51.0",
"globals": "16.5.0", "globals": "16.5.0",
"i18next": "25.6.1", "i18next": "25.6.2",
"i18next-http-backend": "3.0.2", "i18next-http-backend": "3.0.2",
"jquery": "3.7.1", "jquery": "3.7.1",
"jquery.fancytree": "2.38.5", "jquery.fancytree": "2.38.5",

View File

@@ -13,7 +13,6 @@ import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js"; import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js"; import ShortcutComponent from "./shortcut_component.js";
import { t, initLocale } from "../services/i18n.js"; import { t, initLocale } from "../services/i18n.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
@@ -21,8 +20,6 @@ import type LoadResults from "../services/load_results.js";
import type { Attribute } from "../services/attribute_parser.js"; import type { Attribute } from "../services/attribute_parser.js";
import type NoteTreeWidget from "../widgets/note_tree.js"; import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
import type { NativeImage, TouchBar } from "electron"; import type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js"; import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type { CKTextEditor } from "@triliumnext/ckeditor5";
@@ -33,6 +30,10 @@ import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js"; import type RootContainer from "../widgets/containers/root_container.js";
import { SqlExecuteResults } from "@triliumnext/commons"; import { SqlExecuteResults } from "@triliumnext/commons";
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
interface Layout { interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer; getRootWidget: (appContext: AppContext) => RootContainer;
@@ -199,7 +200,7 @@ export type CommandMappings = {
resetLauncher: ContextMenuCommandData; resetLauncher: ContextMenuCommandData;
executeInActiveNoteDetailWidget: CommandData & { executeInActiveNoteDetailWidget: CommandData & {
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void; callback: (value: ReactWrappedWidget) => void;
}; };
executeWithTextEditor: CommandData & executeWithTextEditor: CommandData &
ExecuteCommandData<CKTextEditor> & { ExecuteCommandData<CKTextEditor> & {
@@ -211,7 +212,7 @@ export type CommandMappings = {
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}. * Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
*/ */
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>; executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>; executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
addTextToActiveEditor: CommandData & { addTextToActiveEditor: CommandData & {
text: string; text: string;
}; };
@@ -221,9 +222,9 @@ export type CommandMappings = {
showPasswordNotSet: CommandData; showPasswordNotSet: CommandData;
showProtectedSessionPasswordDialog: CommandData; showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string }; showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; showIncludeNoteDialog: CommandData & IncludeNoteOpts;
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; showAddLinkDialog: CommandData & AddLinkOpts;
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
closeProtectedSessionPasswordDialog: CommandData; closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData; copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData; copyImageToClipboard: CommandData;
@@ -328,6 +329,7 @@ export type CommandMappings = {
exportAsPdf: CommandData; exportAsPdf: CommandData;
openNoteExternally: CommandData; openNoteExternally: CommandData;
openNoteCustom: CommandData; openNoteCustom: CommandData;
openNoteOnServer: CommandData;
renderActiveNote: CommandData; renderActiveNote: CommandData;
unhoist: CommandData; unhoist: CommandData;
reloadFrontendApp: CommandData; reloadFrontendApp: CommandData;
@@ -485,13 +487,8 @@ type EventMappings = {
relationMapResetZoomIn: { ntxId: string | null | undefined }; relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined }; relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {}; activeNoteChanged: {};
showAddLinkDialog: { showAddLinkDialog: AddLinkOpts;
textTypeWidget: EditableTextTypeWidget; showIncludeDialog: IncludeNoteOpts;
text: string;
};
showIncludeDialog: {
textTypeWidget: EditableTextTypeWidget;
};
openBulkActionsDialog: { openBulkActionsDialog: {
selectedOrActiveNoteIds: string[]; selectedOrActiveNoteIds: string[];
}; };
@@ -670,6 +667,10 @@ export class AppContext extends Component {
this.beforeUnloadListeners.push(obj); this.beforeUnloadListeners.push(obj);
} }
} }
removeBeforeUnloadListener(listener: (() => boolean)) {
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
}
} }
const appContext = new AppContext(window.glob.isMainWindow); const appContext = new AppContext(window.glob.isMainWindow);

View File

@@ -9,10 +9,10 @@ import hoistedNoteService from "../services/hoisted_note.js";
import options from "../services/options.js"; import options from "../services/options.js";
import type { ViewScope } from "../services/link.js"; import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.js"; import type FNote from "../entities/fnote.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror"; import type CodeMirror from "@triliumnext/codemirror";
import { closeActiveDialog } from "../services/dialog.js"; import { closeActiveDialog } from "../services/dialog.js";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
export interface SetNoteOpts { export interface SetNoteOpts {
triggerSwitchEvent?: unknown; triggerSwitchEvent?: unknown;
@@ -397,7 +397,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
async getTypeWidget() { async getTypeWidget() {
return this.timeout( return this.timeout(
new Promise<TypeWidget | null>((resolve) => new Promise<ReactWrappedWidget | null>((resolve) =>
appContext.triggerCommand("executeWithTypeWidget", { appContext.triggerCommand("executeWithTypeWidget", {
resolve, resolve,
ntxId: this.ntxId ntxId: this.ntxId

View File

@@ -66,6 +66,13 @@ export default class RootCommandExecutor extends Component {
} }
} }
openNoteOnServerCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
if (noteId) {
openService.openNoteOnServer(noteId);
}
}
enterProtectedSessionCommand() { enterProtectedSessionCommand() {
protectedSessionService.enterProtectedSession(); protectedSessionService.enterProtectedSession();
} }

View File

@@ -265,6 +265,7 @@ export default class TabManager extends Component {
mainNtxId: string | null = null mainNtxId: string | null = null
): Promise<NoteContext> { ): Promise<NoteContext> {
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId); const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
noteContext.setEmpty();
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId); const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);

View File

@@ -14,7 +14,6 @@ import LauncherContainer from "../widgets/containers/launcher_container.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import NoteIconWidget from "../widgets/note_icon.jsx"; import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteList from "../widgets/collections/NoteList.jsx"; import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx";
@@ -22,7 +21,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js"; import NoteWrapperWidget from "../widgets/note_wrapper.js";
import options from "../services/options.js"; import options from "../services/options.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js"; import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx"; import Ribbon from "../widgets/ribbon/Ribbon.jsx";
@@ -44,6 +42,8 @@ import type { WidgetsByParent } from "../services/bundle.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
export default class DesktopLayout { export default class DesktopLayout {
@@ -140,9 +140,9 @@ export default class DesktopLayout {
.child(<ReadOnlyNoteInfoBar />) .child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />) .child(<SharedInfo />)
) )
.child(new PromotedAttributesWidget()) .child(<PromotedAttributes />)
.child(<SqlTableSchemas />) .child(<SqlTableSchemas />)
.child(new NoteDetailWidget()) .child(<NoteDetail />)
.child(<NoteList media="screen" />) .child(<NoteList media="screen" />)
.child(<SearchResult />) .child(<SearchResult />)
.child(<SqlResults />) .child(<SqlResults />)

View File

@@ -26,11 +26,11 @@ import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
import NoteIconWidget from "../widgets/note_icon"; import NoteIconWidget from "../widgets/note_icon";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx"; import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx";
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js"; import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx"; import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx"; import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
export function applyModals(rootContainer: RootContainer) { export function applyModals(rootContainer: RootContainer) {
@@ -66,7 +66,7 @@ export function applyModals(rootContainer: RootContainer) {
.child(<NoteTitleWidget />)) .child(<NoteTitleWidget />))
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />) .child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
.child(new PromotedAttributesWidget()) .child(new PromotedAttributesWidget())
.child(new NoteDetailWidget()) .child(<NoteDetail />)
.child(<NoteList media="screen" displayOnlyCollections />)) .child(<NoteList media="screen" displayOnlyCollections />))
.child(<CallToActionDialog />); .child(<CallToActionDialog />);
} }

View File

@@ -8,8 +8,6 @@ import FloatingButtons from "../widgets/FloatingButtons.jsx";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js"; import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js"; import LauncherContainer from "../widgets/containers/launcher_container.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import NoteList from "../widgets/collections/NoteList.jsx"; import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.js"; import NoteTitleWidget from "../widgets/note_title.js";
import ContentHeader from "../widgets/containers/content-header.js"; import ContentHeader from "../widgets/containers/content-header.js";
@@ -29,6 +27,8 @@ import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibb
import TabRowWidget from "../widgets/tab_row.js"; import TabRowWidget from "../widgets/tab_row.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx"; import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import type AppContext from "../components/app_context.js"; import type AppContext from "../components/app_context.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
const MOBILE_CSS = ` const MOBILE_CSS = `
<style> <style>
@@ -161,7 +161,7 @@ export default class MobileLayout {
.child(<ReadOnlyNoteInfoBar />) .child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />) .child(<SharedInfoWidget />)
) )
.child(new NoteDetailWidget()) .child(<NoteDetail />)
.child(<NoteList media="screen" />) .child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />) .child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
.child(<SearchResult />) .child(<SearchResult />)

View File

@@ -11,7 +11,7 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
import ws from "./ws.js"; import ws from "./ws.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js"; import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
import BasicWidget from "../widgets/basic_widget.js"; import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import SpacedUpdate from "./spaced_update.js"; import SpacedUpdate from "./spaced_update.js";
import shortcutService from "./shortcuts.js"; import shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js"; import dialogService from "./dialog.js";
@@ -19,7 +19,6 @@ import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type NoteContext from "../components/note_context.js"; import type NoteContext from "../components/note_context.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type Component from "../components/component.js"; import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons"; import { formatLogMessage } from "@triliumnext/commons";
@@ -317,7 +316,7 @@ export interface Api {
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
* implementation of actual widget type. * implementation of actual widget type.
*/ */
getActiveNoteDetailWidget(): Promise<NoteDetailWidget>; getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>;
/** /**
* @returns returns a note path of active note or null if there isn't active note * @returns returns a note path of active note or null if there isn't active note
*/ */

View File

@@ -1,6 +1,6 @@
import server from "./server.js"; import server from "./server.js";
import appContext, { type CommandNames } from "../components/app_context.js"; import appContext from "../components/app_context.js";
import shortcutService from "./shortcuts.js"; import shortcutService, { ShortcutBinding } from "./shortcuts.js";
import type Component from "../components/component.js"; import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons"; import type { ActionKeyboardShortcut } from "@triliumnext/commons";
@@ -30,12 +30,18 @@ async function getActionsForScope(scope: string) {
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) { async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
const actions = await getActionsForScope(scope); const actions = await getActionsForScope(scope);
const bindings: ShortcutBinding[] = [];
for (const action of actions) { 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 })); const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
if (binding) {
bindings.push(binding);
}
} }
} }
return bindings;
} }
getActionsForScope("window").then((actions) => { getActionsForScope("window").then((actions) => {

View File

@@ -280,7 +280,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly. * @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise. * @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
*/ */
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) { export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) { if (hrefLink?.startsWith("data:")) {
return true; return true;
} }

View File

@@ -1,4 +1,5 @@
import utils from "./utils.js"; import utils from "./utils.js";
import options from "./options.js";
import server from "./server.js"; import server from "./server.js";
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void; type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
@@ -126,7 +127,7 @@ function downloadRevision(noteId: string, revisionId: string) {
/** /**
* @param url - should be without initial slash!!! * @param url - should be without initial slash!!!
*/ */
function getUrlForDownload(url: string) { export function getUrlForDownload(url: string) {
if (utils.isElectron()) { if (utils.isElectron()) {
// electron needs absolute URL, so we extract current host, port, protocol // electron needs absolute URL, so we extract current host, port, protocol
return `${getHost()}/${url}`; return `${getHost()}/${url}`;
@@ -171,6 +172,21 @@ function getHost() {
return `${url.protocol}//${url.hostname}:${url.port}`; return `${url.protocol}//${url.hostname}:${url.port}`;
} }
async function openNoteOnServer(noteId: string) {
// Get the sync server host from options
const syncServerHost = options.get("syncServerHost");
if (!syncServerHost) {
console.error("No sync server host configured");
return;
}
const url = new URL(`#root/${noteId}`, syncServerHost).toString();
// Use window.open to ensure link opens in external browser in Electron
window.open(url, '_blank', 'noopener,noreferrer');
}
async function openDirectory(directory: string) { async function openDirectory(directory: string) {
try { try {
if (utils.isElectron()) { if (utils.isElectron()) {
@@ -198,5 +214,6 @@ export default {
openAttachmentExternally, openAttachmentExternally,
openNoteCustom, openNoteCustom,
openAttachmentCustom, openAttachmentCustom,
openNoteOnServer,
openDirectory openDirectory
}; };

View File

@@ -3,7 +3,7 @@ import utils from "./utils.js";
type ElementType = HTMLElement | Document; type ElementType = HTMLElement | Document;
type Handler = (e: KeyboardEvent) => void; type Handler = (e: KeyboardEvent) => void;
interface ShortcutBinding { export interface ShortcutBinding {
element: HTMLElement | Document; element: HTMLElement | Document;
shortcut: string; shortcut: string;
handler: Handler; handler: Handler;
@@ -127,10 +127,20 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
activeBindings.set(key, []); activeBindings.set(key, []);
} }
activeBindings.get(key)!.push(binding); activeBindings.get(key)!.push(binding);
return binding;
} }
} }
} }
export function removeIndividualBinding(binding: ShortcutBinding) {
const key = binding.namespace ?? "global";
const activeBindingsInNamespace = activeBindings.get(key);
if (activeBindingsInNamespace) {
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
}
binding.element.removeEventListener("keydown", binding.listener);
}
function removeNamespaceBindings(namespace: string) { function removeNamespaceBindings(namespace: string) {
const bindings = activeBindings.get(namespace); const bindings = activeBindings.get(namespace);
if (bindings) { if (bindings) {

View File

@@ -173,7 +173,7 @@ const entityMap: Record<string, string> = {
"=": "&#x3D;" "=": "&#x3D;"
}; };
function escapeHtml(str: string) { export function escapeHtml(str: string) {
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]); return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
} }

View File

@@ -407,7 +407,7 @@ body.desktop .tabulator-popup-container,
.dropdown-menu .disabled .disabled-tooltip { .dropdown-menu .disabled .disabled-tooltip {
pointer-events: all; pointer-events: all;
margin-inline-start: 8px; margin-inline-start: 8px;
font-size: 0.5em; font-size: 0.75rem;
color: var(--disabled-tooltip-icon-color); color: var(--disabled-tooltip-icon-color);
cursor: help; cursor: help;
opacity: 0.75; opacity: 0.75;

View File

@@ -581,9 +581,14 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
padding: 0; padding: 0;
} }
.note-list-wrapper .note-book-card .note-book-content.type-code {
height: 100%;
}
.note-list-wrapper .note-book-card .note-book-content.type-code pre { .note-list-wrapper .note-book-card .note-book-content.type-code pre {
height: 100%; height: 100%;
padding: 1em; padding: 1em;
margin-bottom: 0;
} }
.note-list-wrapper .note-book-card .bx { .note-list-wrapper .note-book-card .bx {

View File

@@ -42,7 +42,7 @@ div.promoted-attributes-container {
*/ */
/* The property label */ /* The property label */
.note-info-widget-table th, .note-info-item > span:first-child,
.file-properties-widget .file-table th, .file-properties-widget .file-table th,
.image-properties > div:first-child > span > strong { .image-properties > div:first-child > span > strong {
opacity: 0.65; opacity: 0.65;
@@ -50,7 +50,6 @@ div.promoted-attributes-container {
vertical-align: top; vertical-align: top;
} }
.note-info-widget-table td,
.file-properties-widget .file-table td { .file-properties-widget .file-table td {
vertical-align: top; vertical-align: top;
} }

View File

@@ -995,7 +995,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:", "enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>", "start_session_button": "开始受保护的会话",
"started": "受保护的会话已启动。", "started": "受保护的会话已启动。",
"wrong_password": "密码错误。", "wrong_password": "密码错误。",
"protecting-finished-successfully": "保护操作已成功完成。", "protecting-finished-successfully": "保护操作已成功完成。",

View File

@@ -989,7 +989,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:", "enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>", "start_session_button": "Starte eine geschützte Sitzung",
"started": "Geschützte Sitzung gestartet.", "started": "Geschützte Sitzung gestartet.",
"wrong_password": "Passwort falsch.", "wrong_password": "Passwort falsch.",
"protecting-finished-successfully": "Geschützt erfolgreich beendet.", "protecting-finished-successfully": "Geschützt erfolgreich beendet.",

View File

@@ -682,6 +682,7 @@
"open_note_externally": "Open note externally", "open_note_externally": "Open note externally",
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.", "open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
"open_note_custom": "Open note custom", "open_note_custom": "Open note custom",
"open_note_on_server": "Open note on server",
"import_files": "Import files", "import_files": "Import files",
"export_note": "Export note", "export_note": "Export note",
"delete_note": "Delete note", "delete_note": "Delete note",
@@ -995,7 +996,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Showing protected note requires entering your password:", "enter_password_instruction": "Showing protected note requires entering your password:",
"start_session_button": "Start protected session <kbd>enter</kbd>", "start_session_button": "Start protected session",
"started": "Protected session has been started.", "started": "Protected session has been started.",
"wrong_password": "Wrong password.", "wrong_password": "Wrong password.",
"protecting-finished-successfully": "Protecting finished successfully.", "protecting-finished-successfully": "Protecting finished successfully.",

View File

@@ -39,7 +39,10 @@
"help_on_tree_prefix": "Ayuda sobre el prefijo del árbol", "help_on_tree_prefix": "Ayuda sobre el prefijo del árbol",
"prefix": "Prefijo: ", "prefix": "Prefijo: ",
"save": "Guardar", "save": "Guardar",
"branch_prefix_saved": "Se ha guardado el prefijo de rama." "branch_prefix_saved": "Se ha guardado el prefijo de rama.",
"edit_branch_prefix_multiple": "Editar prefijo de rama para {{count}} ramas",
"branch_prefix_saved_multiple": "El prefijo de rama se ha guardado para {{count}} ramas.",
"affected_branches": "Ramas afectadas ({{count}}):"
}, },
"bulk_actions": { "bulk_actions": {
"bulk_actions": "Acciones en bloque", "bulk_actions": "Acciones en bloque",
@@ -992,7 +995,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:", "enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
"start_session_button": "Iniciar sesión protegida <kbd>Enter</kbd>", "start_session_button": "Iniciar sesión protegida",
"started": "La sesión protegida ha iniciado.", "started": "La sesión protegida ha iniciado.",
"wrong_password": "Contraseña incorrecta.", "wrong_password": "Contraseña incorrecta.",
"protecting-finished-successfully": "La protección finalizó exitosamente.", "protecting-finished-successfully": "La protección finalizó exitosamente.",
@@ -1107,7 +1110,8 @@
"title": "Ancho del contenido", "title": "Ancho del contenido",
"default_description": "Trilium limita de forma predeterminada el ancho máximo del contenido para mejorar la legibilidad de ventanas maximizadas en pantallas anchas.", "default_description": "Trilium limita de forma predeterminada el ancho máximo del contenido para mejorar la legibilidad de ventanas maximizadas en pantallas anchas.",
"max_width_label": "Ancho máximo del contenido en píxeles", "max_width_label": "Ancho máximo del contenido en píxeles",
"max_width_unit": "píxeles" "max_width_unit": "píxeles",
"centerContent": "Mantener el contenido centrado"
}, },
"native_title_bar": { "native_title_bar": {
"title": "Barra de título nativa (requiere reiniciar la aplicación)", "title": "Barra de título nativa (requiere reiniciar la aplicación)",
@@ -2079,5 +2083,14 @@
}, },
"collections": { "collections": {
"rendering_error": "No se puede mostrar contenido debido a un error." "rendering_error": "No se puede mostrar contenido debido a un error."
},
"read-only-info": {
"read-only-note": "Actualmente, está viendo una nota de solo lectura.",
"auto-read-only-note": "Esta nota se muestra en modo de solo lectura para una carga más rápida.",
"auto-read-only-learn-more": "Para saber más",
"edit-note": "Editar nota"
},
"calendar_view": {
"delete_note": "Eliminar nota..."
} }
} }

View File

@@ -991,7 +991,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :", "enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :",
"start_session_button": "Démarrer une session protégée <kbd>Entrée</kbd>", "start_session_button": "Démarrer une session protégée",
"started": "La session protégée a démarré.", "started": "La session protégée a démarré.",
"wrong_password": "Mot de passe incorrect.", "wrong_password": "Mot de passe incorrect.",
"protecting-finished-successfully": "La protection de la note s'est terminée avec succès.", "protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",

View File

@@ -39,7 +39,10 @@
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero", "help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
"prefix": "Prefisso: ", "prefix": "Prefisso: ",
"save": "Salva", "save": "Salva",
"branch_prefix_saved": "Il prefisso del ramo è stato salvato." "branch_prefix_saved": "Il prefisso del ramo è stato salvato.",
"edit_branch_prefix_multiple": "Modifica prefisso ramo per {{count}} rami",
"branch_prefix_saved_multiple": "Il prefisso del ramo è stato salvato per {{count}} rami.",
"affected_branches": "Rami interessati ({{count}}):"
}, },
"bulk_actions": { "bulk_actions": {
"bulk_actions": "Azioni massive", "bulk_actions": "Azioni massive",
@@ -1499,7 +1502,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:", "enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:",
"start_session_button": "Avvia sessione protetta <kbd>invio</kbd>", "start_session_button": "Avvia sessione protetta",
"started": "La sessione protetta è stata avviata.", "started": "La sessione protetta è stata avviata.",
"wrong_password": "Password errata.", "wrong_password": "Password errata.",
"protecting-finished-successfully": "Protezione completata con successo.", "protecting-finished-successfully": "Protezione completata con successo.",
@@ -1570,7 +1573,8 @@
"title": "Larghezza del contenuto", "title": "Larghezza del contenuto",
"default_description": "Per impostazione predefinita, Trilium limita la larghezza massima del contenuto per migliorare la leggibilità sugli schermi più grandi.", "default_description": "Per impostazione predefinita, Trilium limita la larghezza massima del contenuto per migliorare la leggibilità sugli schermi più grandi.",
"max_width_label": "Larghezza massima del contenuto", "max_width_label": "Larghezza massima del contenuto",
"max_width_unit": "pixel" "max_width_unit": "pixel",
"centerContent": "Mantieni il contenuto centrato"
}, },
"native_title_bar": { "native_title_bar": {
"title": "Barra del titolo nativa (richiede il riavvio dell'app)", "title": "Barra del titolo nativa (richiede il riavvio dell'app)",
@@ -2080,5 +2084,14 @@
}, },
"collections": { "collections": {
"rendering_error": "Impossibile mostrare il contenuto a causa di un errore." "rendering_error": "Impossibile mostrare il contenuto a causa di un errore."
},
"read-only-info": {
"read-only-note": "Stai visualizzando una nota di sola lettura.",
"auto-read-only-note": "Questa nota viene visualizzata in modalità di sola lettura per un caricamento più rapido.",
"auto-read-only-learn-more": "Per saperne di più",
"edit-note": "Modifica nota"
},
"calendar_view": {
"delete_note": "Eliminazione nota..."
} }
} }

View File

@@ -836,7 +836,8 @@
"title": "コンテンツ幅", "title": "コンテンツ幅",
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。", "default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
"max_width_label": "最大コンテンツ幅", "max_width_label": "最大コンテンツ幅",
"max_width_unit": "ピクセル" "max_width_unit": "ピクセル",
"centerContent": "コンテンツを中央に配置"
}, },
"theme": { "theme": {
"title": "アプリのテーマ", "title": "アプリのテーマ",
@@ -1783,7 +1784,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:", "enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:",
"start_session_button": "保護されたセッションを開始 <kbd>enter</kbd>", "start_session_button": "保護されたセッションを開始",
"started": "保護されたセッションが開始されました。", "started": "保護されたセッションが開始されました。",
"wrong_password": "パスワードが間違っています。", "wrong_password": "パスワードが間違っています。",
"protecting-finished-successfully": "保護が正常に完了しました。", "protecting-finished-successfully": "保護が正常に完了しました。",
@@ -2082,5 +2083,11 @@
}, },
"calendar_view": { "calendar_view": {
"delete_note": "ノートを削除..." "delete_note": "ノートを削除..."
},
"read-only-info": {
"read-only-note": "現在、読み取り専用のノートを表示しています。",
"auto-read-only-note": "このノートは読み込みを高速化するために読み取り専用モードで表示されています。",
"auto-read-only-learn-more": "さらに詳しく",
"edit-note": "ノートを編集"
} }
} }

View File

@@ -967,7 +967,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:", "enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:",
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>", "start_session_button": "Iniciar sessão protegida",
"started": "A sessão protegida foi iniciada.", "started": "A sessão protegida foi iniciada.",
"wrong_password": "Palavra-passe incorreta.", "wrong_password": "Palavra-passe incorreta.",
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.", "protecting-finished-successfully": "A proteção foi finalizada com sucesso.",

View File

@@ -1218,7 +1218,7 @@
"unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}", "unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
"protecting-title": "Estado da proteção", "protecting-title": "Estado da proteção",
"unprotecting-title": "Estado da remoção de proteção", "unprotecting-title": "Estado da remoção de proteção",
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>" "start_session_button": "Iniciar sessão protegida"
}, },
"relation_map": { "relation_map": {
"open_in_new_tab": "Abrir em nova aba", "open_in_new_tab": "Abrir em nova aba",

View File

@@ -982,7 +982,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:", "enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
"start_session_button": "Deschide sesiunea protejată <kbd>enter</kbd>", "start_session_button": "Deschide sesiunea protejată",
"started": "Sesiunea protejată este activă.", "started": "Sesiunea protejată este activă.",
"wrong_password": "Parolă greșită.", "wrong_password": "Parolă greșită.",
"protecting-finished-successfully": "Protejarea a avut succes.", "protecting-finished-successfully": "Protejarea a avut succes.",

View File

@@ -1685,7 +1685,7 @@
"unprotecting-title": "Статус снятия защиты", "unprotecting-title": "Статус снятия защиты",
"protecting-finished-successfully": "Защита успешно завершена.", "protecting-finished-successfully": "Защита успешно завершена.",
"unprotecting-finished-successfully": "Снятие защиты успешно завершено.", "unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
"start_session_button": "Начать защищенный сеанс <kbd>enter</kbd>", "start_session_button": "Начать защищенный сеанс",
"protecting-in-progress": "Защита в процессе: {{count}}", "protecting-in-progress": "Защита в процессе: {{count}}",
"unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}", "unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
"started": "Защищенный сеанс запущен.", "started": "Защищенный сеанс запущен.",

View File

@@ -992,7 +992,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:", "enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>", "start_session_button": "開始受保護的作業階段",
"started": "已啟動受保護的作業階段。", "started": "已啟動受保護的作業階段。",
"wrong_password": "密碼錯誤。", "wrong_password": "密碼錯誤。",
"protecting-finished-successfully": "已成功完成保護操作。", "protecting-finished-successfully": "已成功完成保護操作。",

View File

@@ -1089,7 +1089,7 @@
}, },
"protected_session": { "protected_session": {
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:", "enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
"start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>", "start_session_button": "Розпочати захищений сеанс",
"started": "Захищений сеанс розпочато.", "started": "Захищений сеанс розпочато.",
"wrong_password": "Неправильний пароль.", "wrong_password": "Неправильний пароль.",
"protecting-finished-successfully": "Захист успішно завершено.", "protecting-finished-successfully": "Захист успішно завершено.",

View File

@@ -60,3 +60,14 @@ declare global {
windowControlsOverlay?: unknown; windowControlsOverlay?: unknown;
} }
} }
declare module "preact" {
namespace JSX {
interface IntrinsicElements {
webview: {
src: string;
class: string;
}
}
}
}

View File

@@ -118,11 +118,17 @@ declare global {
filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void; filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
}); });
interface PanZoomTransform {
x: number;
y: number;
scale: number;
}
interface PanZoom { interface PanZoom {
zoomTo(x: number, y: number, scale: number); zoomTo(x: number, y: number, scale: number);
moveTo(x: number, y: number); moveTo(x: number, y: number);
on(event: string, callback: () => void); on(event: string, callback: () => void);
getTransform(): unknown; getTransform(): PanZoomTransform;
dispose(): void; dispose(): void;
} }
} }

View File

@@ -67,7 +67,7 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
] ]
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) { function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode; const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
return isEnabled && <FloatingButton return isEnabled && <FloatingButton
text={t("backend_log.refresh")} text={t("backend_log.refresh")}
icon="bx bx-refresh" icon="bx bx-refresh"
@@ -89,7 +89,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) { function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = (note.type === "mermaid" || viewType === "geoMap") const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
&& note.isContentAvailable() && isDefaultViewMode; && note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton return isEnabled && <FloatingButton
@@ -240,7 +240,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) { function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const hiddenImageCopyRef = useRef<HTMLDivElement>(null); const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "") const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode; && note?.isContentAvailable() && isDefaultViewMode;
return isEnabled && ( return isEnabled && (

View File

@@ -0,0 +1,15 @@
.component.note-detail {
max-width: var(--max-content-width); /* Inherited from .note-split */
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
contain: none;
}
body.prefers-centered-content .note-detail {
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
margin-inline: auto;
}
.note-detail > * {
contain: none;
}

View File

@@ -0,0 +1,324 @@
import { useNoteContext, useTriliumEvent } from "./react/hooks"
import FNote from "../entities/fnote";
import protected_session_holder from "../services/protected_session_holder";
import { useEffect, useRef, useState } from "preact/hooks";
import NoteContext from "../components/note_context";
import { isValidElement, VNode } from "preact";
import { TypeWidgetProps } from "./type_widgets/type_widget";
import "./NoteDetail.css";
import attributes from "../services/attributes";
import { ExtendedNoteType, TYPE_MAPPINGS, TypeWidget } from "./note_types";
import { dynamicRequire, isElectron, isMobile } from "../services/utils";
import toast from "../services/toast.js";
import { t } from "../services/i18n";
/**
* The note detail is in charge of rendering the content of a note, by determining its type (e.g. text, code) and using the appropriate view widget.
*
* Apart from that, it:
* - Applies a full-height style depending on the content type (e.g. canvas notes).
* - Focuses the content when switching tabs.
* - Caches the note type elements based on what the user has accessed, in order to quickly load it again.
* - Fixes the tree for launch bar configurations on mobile.
* - Provides scripting events such as obtaining the active note detail widget, or note type widget.
* - Printing and exporting to PDF.
*/
export default function NoteDetail() {
const containerRef = useRef<HTMLDivElement>(null);
const { note, type, mime, noteContext, parentComponent } = useNoteInfo();
const { ntxId, viewScope } = noteContext ?? {};
const isFullHeight = checkFullHeight(noteContext, type);
const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
const props: TypeWidgetProps = {
note: note!,
viewScope,
ntxId,
parentComponent,
noteContext
};
useEffect(() => {
if (!type) return;
if (!noteTypesToRender.current[type]) {
getCorrespondingWidget(type).then((el) => {
if (!el) return;
noteTypesToRender.current[type] = el;
setActiveNoteType(type);
});
} else {
setActiveNoteType(type);
}
}, [ note, viewScope, type ]);
// Detect note type changes.
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
if (!note) return;
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
// times if the same note is open in several tabs.
if (note.noteId && loadResults.isNoteContentReloaded(note.noteId, parentComponent.componentId)) {
// probably incorrect event
// calling this.refresh() is not enough since the event needs to be propagated to children as well
// FIXME: create a separate event to force hierarchical refresh
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
// to avoid the problem in #3365
parentComponent.handleEvent("noteTypeMimeChanged", { noteId: note.noteId });
} else if (note.noteId
&& loadResults.isNoteReloaded(note.noteId, parentComponent.componentId)
&& (type !== (await getWidgetType(note, noteContext)) || mime !== note?.mime)) {
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
} else {
const attrs = loadResults.getAttributeRows();
const label = attrs.find(
(attr) =>
attr.type === "label" &&
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
attributes.isAffecting(attr, note)
);
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"]
.includes(attr.name ?? "") && attributes.isAffecting(attr, note));
if (note.noteId && (label || relation)) {
// probably incorrect event
// calling this.refresh() is not enough since the event needs to be propagated to children as well
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
}
}
});
// Automatically focus the editor.
useTriliumEvent("activeNoteChanged", () => {
// 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")) {
parentComponent.triggerCommand("focusOnDetail", { ntxId });
}
});
// Fixed tree for launch bar config on mobile.
useEffect(() => {
if (!isMobile) return;
const hasFixedTree = noteContext?.hoistedNoteId === "_lbMobileRoot";
document.body.classList.toggle("force-fixed-tree", hasFixedTree);
}, [ note ]);
// Handle toast notifications.
useEffect(() => {
if (!isElectron()) return;
const { ipcRenderer } = dynamicRequire("electron");
const listener = () => {
toast.closePersistent("printing");
};
ipcRenderer.on("print-done", listener);
return () => ipcRenderer.off("print-done", listener);
}, []);
useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
if (!noteContext?.isActive()) return;
callback(parentComponent);
});
useTriliumEvent("executeWithTypeWidget", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId || !activeNoteType || !containerRef.current) return;
const classNameToSearch = TYPE_MAPPINGS[activeNoteType].className;
const componentEl = containerRef.current.querySelector<HTMLElement>(`.${classNameToSearch}`);
if (!componentEl) return;
const component = glob.getComponentByEl(componentEl);
resolve(component);
});
useTriliumEvent("printActiveNote", () => {
if (!noteContext?.isActive() || !note) return;
toast.showPersistent({
icon: "bx bx-loader-circle bx-spin",
message: t("note_detail.printing"),
id: "printing"
});
if (isElectron()) {
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("print-note", {
notePath: noteContext.notePath
});
} else {
const iframe = document.createElement('iframe');
iframe.src = `?print#${noteContext.notePath}`;
iframe.className = "print-iframe";
document.body.appendChild(iframe);
iframe.onload = () => {
if (!iframe.contentWindow) {
toast.closePersistent("printing");
document.body.removeChild(iframe);
return;
}
iframe.contentWindow.addEventListener("note-ready", () => {
toast.closePersistent("printing");
iframe.contentWindow?.print();
document.body.removeChild(iframe);
});
};
}
});
useTriliumEvent("exportAsPdf", () => {
if (!noteContext?.isActive() || !note) return;
toast.showPersistent({
icon: "bx bx-loader-circle bx-spin",
message: t("note_detail.printing_pdf"),
id: "printing"
});
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("export-as-pdf", {
title: note.title,
notePath: noteContext.notePath,
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
landscape: note.hasAttribute("label", "printLandscape")
});
});
return (
<div
ref={containerRef}
class={`note-detail ${isFullHeight ? "full-height" : ""}`}
>
{Object.entries(noteTypesToRender.current).map(([ itemType, Element ]) => {
return <NoteDetailWrapper
Element={Element}
key={itemType}
type={itemType as ExtendedNoteType}
isVisible={type === itemType}
isFullHeight={isFullHeight}
props={props}
/>
})}
</div>
);
}
/**
* Wraps a single note type widget, in order to keep it in the DOM even after the user has switched away to another note type. This allows faster loading of the same note type again. The properties are cached, so that they are updated only
* while the widget is visible, to avoid rendering in the background. When not visible, the DOM element is simply hidden.
*/
function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { Element: (props: TypeWidgetProps) => VNode, type: ExtendedNoteType, isVisible: boolean, isFullHeight: boolean, props: TypeWidgetProps }) {
const [ cachedProps, setCachedProps ] = useState(props);
useEffect(() => {
if (isVisible) {
setCachedProps(props);
} else {
// Do nothing, keep the old props.
}
}, [ props, isVisible ]);
const typeMapping = TYPE_MAPPINGS[type];
return (
<div
className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""} ${isVisible ? "visible" : "hidden-ext"}`}
style={{
height: isFullHeight ? "100%" : ""
}}
>
{ <Element {...cachedProps} /> }
</div>
);
}
/** Manages both note changes and changes to the widget type, which are asynchronous. */
function useNoteInfo() {
const { note: actualNote, noteContext, parentComponent } = useNoteContext();
const [ note, setNote ] = useState<FNote | null | undefined>();
const [ type, setType ] = useState<ExtendedNoteType>();
const [ mime, setMime ] = useState<string>();
function refresh() {
getWidgetType(actualNote, noteContext).then(type => {
setNote(actualNote);
setType(type);
setMime(actualNote?.mime);
});
}
useEffect(refresh, [ actualNote, noteContext, noteContext?.viewScope ]);
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (eventNoteContext?.ntxId !== noteContext?.ntxId) return;
refresh();
});
useTriliumEvent("noteTypeMimeChanged", refresh);
return { note, type, mime, noteContext, parentComponent };
}
async function getCorrespondingWidget(type: ExtendedNoteType): Promise<null | TypeWidget> {
const correspondingType = TYPE_MAPPINGS[type].view;
if (!correspondingType) return null;
const result = await correspondingType();
if ("default" in result) {
return result.default;
} else if (isValidElement(result)) {
// Direct VNode provided.
return result;
} else {
return result;
}
}
async function getWidgetType(note: FNote | null | undefined, noteContext: NoteContext | undefined): Promise<ExtendedNoteType | undefined> {
if (!noteContext) return undefined;
if (!note) {
// If the note is null, then it's a new tab. If it's undefined, then it's not loaded yet.
return note === null ? "empty" : undefined;
}
const type = note.type;
let resultingType: ExtendedNoteType;
if (noteContext?.viewScope?.viewMode === "source") {
resultingType = "readOnlyCode";
} else if (noteContext?.viewScope && noteContext.viewScope.viewMode === "attachments") {
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
} else if (type === "text" && (await noteContext?.isReadOnly())) {
resultingType = "readOnlyText";
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
resultingType = "readOnlyCode";
} else if (type === "text") {
resultingType = "editableText";
} else if (type === "code") {
resultingType = "editableCode";
} else if (type === "launcher") {
resultingType = "doc";
} else {
resultingType = type;
}
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {
resultingType = "protectedSession";
}
return resultingType;
}
function checkFullHeight(noteContext: NoteContext | undefined, type: ExtendedNoteType | undefined) {
if (!noteContext) return false;
// https://github.com/zadam/trilium/issues/2522
const isBackendNote = noteContext?.noteId === "_backendLog";
const isSqlNote = noteContext.note?.mime === "text/x-sqlite;schema=trilium";
const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
return (!noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|| noteContext?.viewScope?.viewMode === "attachments"
|| isBackendNote;
}

View File

@@ -0,0 +1,91 @@
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.component.promoted-attributes-widget {
contain: none;
}
.promoted-attributes-container {
margin: 0 1.5em;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-inline-start: 10px;
display: flex;
min-height: 40px;
}
.promoted-attribute-cell strong {
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;
inset-inline-start: 0px;
inset-inline-end: 0;
height: 2px;
background: rgba(0, 0, 0, 0.5);
transform: rotate(45deg);
pointer-events: none;
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from "preact/hooks";
import "./PromotedAttributes.css";
import { useNoteContext, useNoteLabel } from "./react/hooks";
import { Attribute } from "../services/attribute_parser";
import { ComponentChild } from "preact";
import FAttribute from "../entities/fattribute";
import { t } from "../services/i18n";
import ActionButton from "./react/ActionButton";
export default function PromotedAttributes() {
const { note } = useNoteContext();
const [ promotedAttributes, setPromotedAttributes ] = useState<ComponentChild[]>();
const [ viewType ] = useNoteLabel(note, "viewType");
useEffect(() => {
if (!note) {
setPromotedAttributes([]);
return;
}
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
let promotedAttributes: ComponentChild[] = [];
for (const definitionAttr of promotedDefAttrs) {
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
promotedAttributes.push(<PromotedAttributeCell
noteId={note.noteId}
definitionAttr={definitionAttr}
valueAttr={valueAttr} valueName={valueName} />)
}
}
setPromotedAttributes(promotedAttributes);
console.log("Got ", promotedAttributes);
}, [ note ]);
return (
<div className="promoted-attributes-widget">
{viewType !== "table" && (
<div className="promoted-attributes-container">
{promotedAttributes}
</div>
)}
</div>
);
}
function PromotedAttributeCell({ noteId, definitionAttr, valueAttr, valueName }: {
noteId: string;
definitionAttr: FAttribute;
valueAttr: Attribute;
valueName: string;
}) {
const definition = definitionAttr.getDefinition();
const id = `value-${valueAttr.attributeId}`;
return (
<div className="promoted-attribute-cell">
<label
for={id}
>{definition.promotedAlias ?? valueName}</label>
<div className="input-group">
<input
className="form-control promoted-attribute-input"
tabindex={200 + definitionAttr.position}
id={id}
// if not owned, we'll force creation of a new attribute instead of updating the inherited one
data-attribute-id={valueAttr.noteId === noteId ? valueAttr.attributeId ?? "" : ""}
data-attribute-type={valueAttr.type}
data-attribute-name={valueAttr.name}
value={valueAttr.value}
placeholder={t("promoted_attributes.unset-field-placeholder")}
/>
</div>
<div />
{definition.multiplicity === "multi" && (
<td className="multiplicity">
<ActionButton
icon="bx bx-plus"
className="pointer tn-tool-button"
text={t("promoted_attributes.add_new_attribute")}
noIconActionClass
/>
<ActionButton
icon="bx bx-trash"
className="pointer tn-tool-button"
text={t("promoted_attributes.remove_this_attribute")}
noIconActionClass
/>
</td>
)}
</div>
)
}

View File

@@ -1,207 +0,0 @@
import { t } from "../services/i18n.js";
import utils from "../services/utils.js";
import AttachmentActionsWidget from "./buttons/attachments_actions.js";
import BasicWidget from "./basic_widget.js";
import options from "../services/options.js";
import imageService from "../services/image.js";
import linkService from "../services/link.js";
import contentRenderer from "../services/content_renderer.js";
import toastService from "../services/toast.js";
import type FAttachment from "../entities/fattachment.js";
import type { EventData } from "../components/app_context.js";
const TPL = /*html*/`
<div class="attachment-detail-widget">
<style>
.attachment-detail-widget {
height: 100%;
}
.attachment-detail-wrapper {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.attachment-title-line {
display: flex;
align-items: baseline;
gap: 1em;
}
.attachment-details {
margin-inline-start: 10px;
}
.attachment-content-wrapper {
flex-grow: 1;
}
.attachment-content-wrapper .rendered-content {
height: 100%;
}
.attachment-content-wrapper pre {
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper {
max-height: 300px;
}
.attachment-detail-wrapper.full-detail {
height: 100%;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper {
height: 100%;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper pre {
max-height: 400px;
}
.attachment-content-wrapper img {
margin: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
max-height: 300px;
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper img {
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%);
}
</style>
<div class="attachment-detail-wrapper">
<div class="attachment-title-line">
<div class="attachment-actions-container"></div>
<h4 class="attachment-title"></h4>
<div class="attachment-details"></div>
<div style="flex: 1 1;"></div>
</div>
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
<div class="attachment-content-wrapper"></div>
</div>
</div>`;
export default class AttachmentDetailWidget extends BasicWidget {
attachment: FAttachment;
attachmentActionsWidget: AttachmentActionsWidget;
isFullDetail: boolean;
$wrapper!: JQuery<HTMLElement>;
constructor(attachment: FAttachment, isFullDetail: boolean) {
super();
this.contentSized();
this.attachment = attachment;
this.attachmentActionsWidget = new AttachmentActionsWidget(attachment, isFullDetail);
this.isFullDetail = isFullDetail;
this.child(this.attachmentActionsWidget);
}
doRender() {
this.$widget = $(TPL);
this.refresh();
super.doRender();
}
async refresh() {
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
if (!this.isFullDetail) {
const $link = await linkService.createLink(this.attachment.ownerId, {
title: this.attachment.title,
viewScope: {
viewMode: "attachments",
attachmentId: this.attachment.attachmentId
}
});
$link.addClass("use-tn-links");
this.$wrapper.find(".attachment-title").append($link);
} else {
this.$wrapper.find(".attachment-title").text(this.attachment.title);
}
const $deletionWarning = this.$wrapper.find(".attachment-deletion-warning");
const { utcDateScheduledForErasureSince } = this.attachment;
if (utcDateScheduledForErasureSince) {
this.$wrapper.addClass("scheduled-for-deletion");
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
// use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
const willBeDeletedInMs = deletionTimestamp - Date.now();
$deletionWarning.show();
if (willBeDeletedInMs >= 60000) {
$deletionWarning.text(t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) }));
} else {
$deletionWarning.text(t("attachment_detail_2.will_be_deleted_soon"));
}
$deletionWarning.append(t("attachment_detail_2.deletion_reason"));
} else {
this.$wrapper.removeClass("scheduled-for-deletion");
$deletionWarning.hide();
}
this.$wrapper.find(".attachment-details").text(t("attachment_detail_2.role_and_size", { role: this.attachment.role, size: utils.formatSize(this.attachment.contentLength) }));
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
}
async copyAttachmentLinkToClipboard() {
if (this.attachment.role === "image") {
imageService.copyImageReferenceToClipboard(this.$wrapper.find(".attachment-content-wrapper"));
} else if (this.attachment.role === "file") {
const $link = await linkService.createLink(this.attachment.ownerId, {
referenceLink: true,
viewScope: {
viewMode: "attachments",
attachmentId: this.attachment.attachmentId
}
});
utils.copyHtmlToClipboard($link[0].outerHTML);
toastService.showMessage(t("attachment_detail_2.link_copied"));
} else {
throw new Error(t("attachment_detail_2.unrecognized_role", { role: this.attachment.role }));
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachment.attachmentId);
if (attachmentRow) {
if (attachmentRow.isDeleted) {
this.toggleInt(false);
} else {
this.refresh();
}
}
}
}

View File

@@ -4,8 +4,6 @@ import froca from "../services/froca.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import toastService from "../services/toast.js"; import toastService from "../services/toast.js";
import { renderReactWidget } from "./react/react_utils.jsx"; import { renderReactWidget } from "./react/react_utils.jsx";
import { EventNames, EventData } from "../components/app_context.js";
import { Handler } from "leaflet";
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> { export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
protected attrs: Record<string, string>; protected attrs: Record<string, string>;

View File

@@ -1,195 +0,0 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import server from "../../services/server.js";
import dialogService from "../../services/dialog.js";
import toastService from "../../services/toast.js";
import ws from "../../services/ws.js";
import appContext from "../../components/app_context.js";
import openService from "../../services/open.js";
import utils from "../../services/utils.js";
import { Dropdown } from "bootstrap";
import type FAttachment from "../../entities/fattachment.js";
import type AttachmentDetailWidget from "../attachment_detail.js";
import type { NoteRow } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="dropdown attachment-actions">
<style>
.attachment-actions {
width: 35px;
height: 35px;
}
.attachment-actions .dropdown-menu {
width: 20em;
}
.attachment-actions .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-inline-end: 5px;
}
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
color: var(--muted-text-color) !important;
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"
style="position: relative; top: 3px;"></button>
<div class="dropdown-menu dropdown-menu-right">
<li data-trigger-command="openAttachment" class="dropdown-item"
title="${t("attachments_actions.open_externally_title")}"><span class="bx bx-file-find"></span> ${t("attachments_actions.open_externally")}</li>
<li data-trigger-command="openAttachmentCustom" class="dropdown-item"
title="${t("attachments_actions.open_custom_title")}"><span class="bx bx-customize"></span> ${t("attachments_actions.open_custom")}</li>
<li data-trigger-command="downloadAttachment" class="dropdown-item">
<span class="bx bx-download"></span> ${t("attachments_actions.download")}</li>
<li data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item"><span class="bx bx-link">
</span> ${t("attachments_actions.copy_link_to_clipboard")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item"><span class="bx bx-upload">
</span> ${t("attachments_actions.upload_new_revision")}</li>
<li data-trigger-command="renameAttachment" class="dropdown-item">
<span class="bx bx-rename"></span> ${t("attachments_actions.rename_attachment")}</li>
<li data-trigger-command="deleteAttachment" class="dropdown-item">
<span class="bx bx-trash destructive-action-icon"></span> ${t("attachments_actions.delete_attachment")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="convertAttachmentIntoNote" class="dropdown-item"><span class="bx bx-note">
</span> ${t("attachments_actions.convert_attachment_into_note")}</li>
</div>
<input type="file" class="attachment-upload-new-revision-input" style="display: none">
</div>`;
// TODO: Deduplicate
interface AttachmentResponse {
note: NoteRow;
}
export default class AttachmentActionsWidget extends BasicWidget {
$uploadNewRevisionInput!: JQuery<HTMLInputElement>;
attachment: FAttachment;
isFullDetail: boolean;
dropdown!: Dropdown;
constructor(attachment: FAttachment, isFullDetail: boolean) {
super();
this.attachment = attachment;
this.isFullDetail = isFullDetail;
}
get attachmentId() {
return this.attachment.attachmentId;
}
doRender() {
this.$widget = $(TPL);
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
this.$widget.on("click", ".dropdown-item", () => this.dropdown.toggle());
this.$uploadNewRevisionInput = this.$widget.find(".attachment-upload-new-revision-input");
this.$uploadNewRevisionInput.on("change", async () => {
const fileToUpload = this.$uploadNewRevisionInput[0].files?.item(0); // copy to allow reset below
this.$uploadNewRevisionInput.val("");
if (fileToUpload) {
const result = await server.upload(`attachments/${this.attachmentId}/file`, fileToUpload);
if (result.uploaded) {
toastService.showMessage(t("attachments_actions.upload_success"));
} else {
toastService.showError(t("attachments_actions.upload_failed"));
}
}
});
const isElectron = utils.isElectron();
if (!this.isFullDetail) {
const $openAttachmentButton = this.$widget.find("[data-trigger-command='openAttachment']");
$openAttachmentButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
if (isElectron) {
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
}
}
if (!isElectron) {
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_custom_client_only")));
}
}
async openAttachmentCommand() {
await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime);
}
async openAttachmentCustomCommand() {
await openService.openAttachmentCustom(this.attachmentId, this.attachment.mime);
}
async downloadAttachmentCommand() {
await openService.downloadAttachment(this.attachmentId);
}
async uploadNewAttachmentRevisionCommand() {
this.$uploadNewRevisionInput.trigger("click");
}
async copyAttachmentLinkToClipboardCommand() {
if (this.parent && "copyAttachmentLinkToClipboard" in this.parent) {
(this.parent as AttachmentDetailWidget).copyAttachmentLinkToClipboard();
}
}
async deleteAttachmentCommand() {
if (!(await dialogService.confirm(t("attachments_actions.delete_confirm", { title: this.attachment.title })))) {
return;
}
await server.remove(`attachments/${this.attachmentId}`);
toastService.showMessage(t("attachments_actions.delete_success", { title: this.attachment.title }));
}
async convertAttachmentIntoNoteCommand() {
if (!(await dialogService.confirm(t("attachments_actions.convert_confirm", { title: this.attachment.title })))) {
return;
}
const { note: newNote } = await server.post<AttachmentResponse>(`attachments/${this.attachmentId}/convert-to-note`);
toastService.showMessage(t("attachments_actions.convert_success", { title: this.attachment.title }));
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
}
async renameAttachmentCommand() {
const attachmentTitle = await dialogService.prompt({
title: t("attachments_actions.rename_attachment"),
message: t("attachments_actions.enter_new_name"),
defaultValue: this.attachment.title
});
if (!attachmentTitle?.trim()) {
return;
}
await server.put(`attachments/${this.attachmentId}/rename`, { title: attachmentTitle });
}
}

View File

@@ -4,7 +4,7 @@ import Calendar from "./calendar";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import "./index.css"; import "./index.css";
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons"; import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { Calendar as FullCalendar } from "@fullcalendar/core"; import { Calendar as FullCalendar } from "@fullcalendar/core";
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
import dialog from "../../../services/dialog"; import dialog from "../../../services/dialog";

View File

@@ -1,7 +1,7 @@
import Map from "./map"; import Map from "./map";
import "./index.css"; import "./index.css";
import { ViewModeProps } from "../interface"; import { ViewModeProps } from "../interface";
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks"; import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet"; import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";

View File

@@ -0,0 +1,9 @@
.scrolling-container {
overflow: auto;
scroll-behavior: smooth;
position: relative;
}
.note-split.type-code:not(.mime-text-x-sqlite) > .scrolling-container {
background-color: var(--code-background-color);
}

View File

@@ -2,6 +2,7 @@ import type { CommandListenerData, EventData, EventNames } from "../../component
import type NoteContext from "../../components/note_context.js"; import type NoteContext from "../../components/note_context.js";
import type BasicWidget from "../basic_widget.js"; import type BasicWidget from "../basic_widget.js";
import Container from "./container.js"; import Container from "./container.js";
import "./scrolling_container.css";
export default class ScrollingContainer extends Container<BasicWidget> { export default class ScrollingContainer extends Container<BasicWidget> {
@@ -11,9 +12,6 @@ export default class ScrollingContainer extends Container<BasicWidget> {
super(); super();
this.class("scrolling-container"); this.class("scrolling-container");
this.css("overflow", "auto");
this.css("scroll-behavior", "smooth");
this.css("position", "relative");
} }
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) { setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {

View File

@@ -6,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks"; import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree"; import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
import { logError } from "../../services/ws"; import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js"; import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils"; import { refToJQuerySelector } from "../react/react_utils";
@@ -14,29 +13,32 @@ import { useTriliumEvent } from "../react/hooks";
type LinkType = "reference-link" | "external-link" | "hyper-link"; type LinkType = "reference-link" | "external-link" | "hyper-link";
export interface AddLinkOpts {
text: string;
hasSelection: boolean;
addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise<void>;
}
export default function AddLinkDialog() { export default function AddLinkDialog() {
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>(); const [ opts, setOpts ] = useState<AddLinkOpts>();
const initialText = useRef<string>();
const [ linkTitle, setLinkTitle ] = useState(""); const [ linkTitle, setLinkTitle ] = useState("");
const hasSelection = textTypeWidget?.hasSelection(); const [ linkType, setLinkType ] = useState<LinkType>();
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null); const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false); const hasSubmittedRef = useRef(false);
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => { useTriliumEvent("showAddLinkDialog", opts => {
setTextTypeWidget(textTypeWidget); setOpts(opts);
initialText.current = text;
setShown(true); setShown(true);
}); });
useEffect(() => { useEffect(() => {
if (hasSelection) { if (opts?.hasSelection) {
setLinkType("hyper-link"); setLinkType("hyper-link");
} else { } else {
setLinkType("reference-link"); setLinkType("reference-link");
} }
}, [ hasSelection ]) }, [ opts ]);
async function setDefaultLinkTitle(noteId: string) { async function setDefaultLinkTitle(noteId: string) {
const noteTitle = await tree.getNoteTitle(noteId); const noteTitle = await tree.getNoteTitle(noteId);
@@ -71,10 +73,10 @@ export default function AddLinkDialog() {
function onShown() { function onShown() {
const $autocompleteEl = refToJQuerySelector(autocompleteRef); const $autocompleteEl = refToJQuerySelector(autocompleteRef);
if (!initialText.current) { if (!opts?.text) {
note_autocomplete.showRecentNotes($autocompleteEl); note_autocomplete.showRecentNotes($autocompleteEl);
} else { } else {
note_autocomplete.setText($autocompleteEl, initialText.current); note_autocomplete.setText($autocompleteEl, opts.text);
} }
// to be able to quickly remove entered text // to be able to quickly remove entered text
@@ -108,15 +110,15 @@ export default function AddLinkDialog() {
onShown={onShown} onShown={onShown}
onHidden={() => { onHidden={() => {
// Insert the link. // Insert the link.
if (hasSubmittedRef.current && suggestion && textTypeWidget) { if (hasSubmittedRef.current && suggestion && opts) {
hasSubmittedRef.current = false; hasSubmittedRef.current = false;
if (suggestion.notePath) { if (suggestion.notePath) {
// Handle note link // Handle note link
textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) { } else if (suggestion.externalLink) {
// Handle external link // Handle external link
textTypeWidget.addLink(suggestion.externalLink, linkTitle, true); opts.addLink(suggestion.externalLink, linkTitle, true);
} }
} }
@@ -136,7 +138,7 @@ export default function AddLinkDialog() {
/> />
</FormGroup> </FormGroup>
{!hasSelection && ( {!opts?.hasSelection && (
<div className="add-link-title-settings"> <div className="add-link-title-settings">
{(linkType !== "external-link") && ( {(linkType !== "external-link") && (
<> <>

View File

@@ -8,17 +8,21 @@ import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
import tree from "../../services/tree"; import tree from "../../services/tree";
import froca from "../../services/froca"; import froca from "../../services/froca";
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
import { useTriliumEvent } from "../react/hooks"; import { useTriliumEvent } from "../react/hooks";
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
export interface IncludeNoteOpts {
editorApi: CKEditorApi;
}
export default function IncludeNoteDialog() { export default function IncludeNoteDialog() {
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>(); const editorApiRef = useRef<CKEditorApi>(null);
const [suggestion, setSuggestion] = useState<Suggestion | null>(null); const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
const [boxSize, setBoxSize] = useState("medium"); const [boxSize, setBoxSize] = useState<string>("medium");
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);
useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => { useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
setTextTypeWidget(textTypeWidget); editorApiRef.current = editorApi;
setShown(true); setShown(true);
}); });
@@ -32,12 +36,9 @@ export default function IncludeNoteDialog() {
onShown={() => triggerRecentNotes(autoCompleteRef.current)} onShown={() => triggerRecentNotes(autoCompleteRef.current)}
onHidden={() => setShown(false)} onHidden={() => setShown(false)}
onSubmit={() => { onSubmit={() => {
if (!suggestion?.notePath || !textTypeWidget) { if (!suggestion?.notePath || !editorApiRef.current) return;
return;
}
setShown(false); setShown(false);
includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize); includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
}} }}
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />} footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
show={shown} show={shown}
@@ -69,7 +70,7 @@ export default function IncludeNoteDialog() {
) )
} }
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget, boxSize: BoxSize) { async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
const noteId = tree.getNoteIdFromUrl(notePath); const noteId = tree.getNoteIdFromUrl(notePath);
if (!noteId) { if (!noteId) {
return; return;
@@ -79,8 +80,8 @@ async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWid
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) { if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
// there's no benefit to use insert note functionlity for images, // there's no benefit to use insert note functionlity for images,
// so we'll just add an IMG tag // so we'll just add an IMG tag
textTypeWidget.addImage(noteId); editorApi.addImage(noteId);
} else { } else {
textTypeWidget.addIncludeNote(noteId, boxSize); editorApi.addIncludeNote(noteId, boxSize);
} }
} }

View File

@@ -1,5 +1,4 @@
import { useCallback, useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import server from "../../services/server"; import server from "../../services/server";
import toast from "../../services/toast"; import toast from "../../services/toast";
@@ -7,7 +6,11 @@ import utils from "../../services/utils";
import Modal from "../react/Modal"; import Modal from "../react/Modal";
import Button from "../react/Button"; import Button from "../react/Button";
import { useTriliumEvent } from "../react/hooks"; import { useTriliumEvent } from "../react/hooks";
import EditableTextTypeWidget from "../type_widgets/editable_text"; import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
export interface MarkdownImportOpts {
editorApi: CKEditorApi;
}
interface RenderMarkdownResponse { interface RenderMarkdownResponse {
htmlContent: string; htmlContent: string;
@@ -15,18 +18,18 @@ interface RenderMarkdownResponse {
export default function MarkdownImportDialog() { export default function MarkdownImportDialog() {
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null); const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>(); const editorApiRef = useRef<CKEditorApi>(null);
const [ text, setText ] = useState(""); const [ text, setText ] = useState("");
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => { useTriliumEvent("showPasteMarkdownDialog", ({ editorApi }) => {
setTextTypeWidget(textTypeWidget);
if (utils.isElectron()) { if (utils.isElectron()) {
const { clipboard } = utils.dynamicRequire("electron"); const { clipboard } = utils.dynamicRequire("electron");
const text = clipboard.readText(); const text = clipboard.readText();
convertMarkdownToHtml(text, textTypeWidget); convertMarkdownToHtml(text, editorApi);
} else { } else {
editorApiRef.current = editorApi;
setShown(true); setShown(true);
} }
}); });
@@ -37,8 +40,8 @@ export default function MarkdownImportDialog() {
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />} footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
onShown={() => markdownImportTextArea.current?.focus()} onShown={() => markdownImportTextArea.current?.focus()}
onHidden={async () => { onHidden={async () => {
if (textTypeWidget) { if (editorApiRef.current) {
await convertMarkdownToHtml(text, textTypeWidget); await convertMarkdownToHtml(text, editorApiRef.current);
} }
setShown(false); setShown(false);
setText(""); setText("");
@@ -59,10 +62,8 @@ export default function MarkdownImportDialog() {
) )
} }
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) { async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: CKEditorApi) {
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent }); const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
textTypeWidget.addHtmlToEditor(htmlContent);
await textTypeWidget.addHtmlToEditor(htmlContent);
toast.showMessage(t("markdown_import.import_success")); toast.showMessage(t("markdown_import.import_success"));
} }

View File

@@ -1,9 +1,8 @@
import type { EventNames, EventData } from "../../components/app_context.js"; import type { EventNames, EventData } from "../../components/app_context.js";
import NoteContext from "../../components/note_context.js"; import NoteContext from "../../components/note_context.js";
import { openDialog } from "../../services/dialog.js"; import { openDialog } from "../../services/dialog.js";
import BasicWidget from "../basic_widget.js"; import BasicWidget, { ReactWrappedWidget } from "../basic_widget.js";
import Container from "../containers/container.js"; import Container from "../containers/container.js";
import TypeWidget from "../type_widgets/type_widget.js";
const TPL = /*html*/`\ const TPL = /*html*/`\
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog"> <div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
@@ -149,7 +148,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
$dialog.on("hidden.bs.modal", () => { $dialog.on("hidden.bs.modal", () => {
const $typeWidgetEl = $dialog.find(".note-detail-printable"); const $typeWidgetEl = $dialog.find(".note-detail-printable");
if ($typeWidgetEl.length) { if ($typeWidgetEl.length) {
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget; const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
typeWidget.cleanup(); typeWidget.cleanup();
} }

View File

@@ -1,464 +0,0 @@
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
import keyboardActionsService from "../services/keyboard_actions.js";
import noteCreateService from "../services/note_create.js";
import attributeService from "../services/attributes.js";
import EmptyTypeWidget from "./type_widgets/empty.js";
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
import EditableCodeTypeWidget from "./type_widgets/editable_code.js";
import FileTypeWidget from "./type_widgets/file.js";
import ImageTypeWidget from "./type_widgets/image.js";
import RenderTypeWidget from "./type_widgets/render.js";
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
import CanvasTypeWidget from "./type_widgets/canvas.js";
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
import BookTypeWidget from "./type_widgets/book.js";
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js";
import NoneTypeWidget from "./type_widgets/none.js";
import NoteMapTypeWidget from "./type_widgets/note_map.js";
import WebViewTypeWidget from "./type_widgets/web_view.js";
import DocTypeWidget from "./type_widgets/doc.js";
import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
import MindMapWidget from "./type_widgets/mind_map.js";
import utils, { isElectron } from "../services/utils.js";
import type { NoteType } from "../entities/fnote.js";
import type TypeWidget from "./type_widgets/type_widget.js";
import { MermaidTypeWidget } from "./type_widgets/mermaid.js";
import AiChatTypeWidget from "./type_widgets/ai_chat.js";
import toast from "../services/toast.js";
const TPL = /*html*/`
<div class="note-detail">
<style>
.note-detail {
max-width: var(--max-content-width); /* Inherited from .note-split */
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
}
body.prefers-centered-content .note-detail {
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
margin-inline: auto;
}
</style>
</div>
`;
const typeWidgetClasses = {
empty: EmptyTypeWidget,
editableText: EditableTextTypeWidget,
readOnlyText: ReadOnlyTextTypeWidget,
editableCode: EditableCodeTypeWidget,
readOnlyCode: ReadOnlyCodeTypeWidget,
file: FileTypeWidget,
image: ImageTypeWidget,
search: NoneTypeWidget,
render: RenderTypeWidget,
relationMap: RelationMapTypeWidget,
canvas: CanvasTypeWidget,
protectedSession: ProtectedSessionTypeWidget,
book: BookTypeWidget,
noteMap: NoteMapTypeWidget,
webView: WebViewTypeWidget,
doc: DocTypeWidget,
contentWidget: ContentWidgetTypeWidget,
attachmentDetail: AttachmentDetailTypeWidget,
attachmentList: AttachmentListTypeWidget,
mindMap: MindMapWidget,
aiChat: AiChatTypeWidget,
// Split type editors
mermaid: MermaidTypeWidget
};
/**
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
type ExtendedNoteType =
| Exclude<NoteType, "launcher" | "text" | "code">
| "empty"
| "readOnlyCode"
| "readOnlyText"
| "editableText"
| "editableCode"
| "attachmentDetail"
| "attachmentList"
| "protectedSession"
| "aiChat";
export default class NoteDetailWidget extends NoteContextAwareWidget {
private typeWidgets: Record<string, TypeWidget>;
private spacedUpdate: SpacedUpdate;
private type?: ExtendedNoteType;
private mime?: string;
constructor() {
super();
this.typeWidgets = {};
this.spacedUpdate = new SpacedUpdate(async () => {
if (!this.noteContext) {
return;
}
const { note } = this.noteContext;
if (!note) {
return;
}
const { noteId } = note;
const data = await this.getTypeWidget().getData();
// for read only notes
if (data === undefined) {
return;
}
protectedSessionHolder.touchProtectedSessionIfNecessary(note);
await server.put(`notes/${noteId}/data`, data, this.componentId);
this.getTypeWidget().dataSaved();
});
appContext.addBeforeUnloadListener(this);
}
isEnabled() {
return true;
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
if (utils.isElectron()) {
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.on("print-done", () => {
toast.closePersistent("printing");
});
}
}
async refresh() {
this.type = await this.getWidgetType();
this.mime = this.note?.mime;
if (!(this.type in this.typeWidgets)) {
const clazz = typeWidgetClasses[this.type];
if (!clazz) {
throw new Error(`Cannot find type widget for type '${this.type}'`);
}
const typeWidget = (this.typeWidgets[this.type] = new clazz());
typeWidget.spacedUpdate = this.spacedUpdate;
typeWidget.setParent(this);
if (this.noteContext) {
typeWidget.setNoteContextEvent({ noteContext: this.noteContext });
}
const $renderedWidget = typeWidget.render();
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
this.$widget.append($renderedWidget);
if (this.noteContext) {
await typeWidget.handleEvent("setNoteContext", { noteContext: this.noteContext });
}
// this is happening in update(), so note has been already set, and we need to reflect this
if (this.noteContext) {
await typeWidget.handleEvent("noteSwitched", {
noteContext: this.noteContext,
notePath: this.noteContext.notePath
});
}
this.child(typeWidget);
}
this.checkFullHeight();
if (utils.isMobile()) {
const hasFixedTree = this.noteContext?.hoistedNoteId === "_lbMobileRoot";
$("body").toggleClass("force-fixed-tree", hasFixedTree);
}
}
/**
* sets full height of container that contains note content for a subset of note-types
*/
checkFullHeight() {
// https://github.com/zadam/trilium/issues/2522
const isBackendNote = this.noteContext?.noteId === "_backendLog";
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file", "aiChat"].includes(this.type ?? "");
const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|| this.noteContext?.viewScope?.viewMode === "attachments"
|| isBackendNote;
this.$widget.toggleClass("full-height", isFullHeight);
}
getTypeWidget() {
if (!this.type || !this.typeWidgets[this.type]) {
throw new Error(t(`note_detail.could_not_find_typewidget`, { type: this.type }));
}
return this.typeWidgets[this.type];
}
async getWidgetType(): Promise<ExtendedNoteType> {
const note = this.note;
if (!note) {
return "empty";
}
const type = note.type;
let resultingType: ExtendedNoteType;
const viewScope = this.noteContext?.viewScope;
if (viewScope?.viewMode === "source") {
resultingType = "readOnlyCode";
} else if (viewScope && viewScope.viewMode === "attachments") {
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
resultingType = "readOnlyText";
} else if ((type === "code" || type === "mermaid") && (await this.noteContext?.isReadOnly())) {
resultingType = "readOnlyCode";
} else if (type === "text") {
resultingType = "editableText";
} else if (type === "code") {
resultingType = "editableCode";
} else if (type === "launcher") {
resultingType = "doc";
} else {
resultingType = type;
}
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
resultingType = "protectedSession";
}
return resultingType;
}
async focusOnDetailEvent({ ntxId }: EventData<"focusOnDetail">) {
if (this.noteContext?.ntxId !== ntxId) {
return;
}
await this.refresh();
const widget = this.getTypeWidget();
await widget.initialized;
widget.focus();
}
async scrollToEndEvent({ ntxId }: EventData<"scrollToEnd">) {
if (this.noteContext?.ntxId !== ntxId) {
return;
}
await this.refresh();
const widget = this.getTypeWidget();
await widget.initialized;
if (widget.scrollToEnd) {
widget.scrollToEnd();
}
}
async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) {
if (this.isNoteContext(noteContext.ntxId)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) {
if (this.isNoteContext(ntxIds)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
if (this.isNoteContext(params.ntxId)) {
// make sure that script is saved before running it #4028
await this.spacedUpdate.updateNowIfNecessary();
}
return await this.parent?.triggerCommand("runActiveNote", params);
}
async printActiveNoteEvent() {
if (!this.noteContext?.isActive()) {
return;
}
toast.showPersistent({
icon: "bx bx-loader-circle bx-spin",
message: t("note_detail.printing"),
id: "printing"
});
if (isElectron()) {
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("print-note", {
notePath: this.notePath
});
} else {
const iframe = document.createElement('iframe');
iframe.src = `?print#${this.notePath}`;
iframe.className = "print-iframe";
document.body.appendChild(iframe);
iframe.onload = () => {
if (!iframe.contentWindow) {
toast.closePersistent("printing");
document.body.removeChild(iframe);
return;
}
iframe.contentWindow.addEventListener("note-ready", () => {
toast.closePersistent("printing");
iframe.contentWindow?.print();
document.body.removeChild(iframe);
});
};
}
}
async exportAsPdfEvent() {
if (!this.noteContext?.isActive() || !this.note || !this.notePath) {
return;
}
toast.showPersistent({
icon: "bx bx-loader-circle bx-spin",
message: t("note_detail.printing_pdf"),
id: "printing"
});
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("export-as-pdf", {
title: this.note.title,
notePath: this.notePath,
pageSize: this.note.getAttributeValue("label", "printPageSize") ?? "Letter",
landscape: this.note.hasAttribute("label", "printLandscape")
});
}
hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) {
if (this.isNoteContext(ntxId)) {
this.refresh();
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
// times if the same note is open in several tabs.
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId, this.componentId)) {
// probably incorrect event
// calling this.refresh() is not enough since the event needs to be propagated to children as well
// FIXME: create a separate event to force hierarchical refresh
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
// to avoid the problem in #3365
this.handleEvent("noteTypeMimeChanged", { noteId: this.noteId });
} else if (this.noteId && loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== (await this.getWidgetType()) || this.mime !== this.note?.mime)) {
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
} else {
const attrs = loadResults.getAttributeRows();
const label = attrs.find(
(attr) =>
attr.type === "label" &&
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
attributeService.isAffecting(attr, this.note)
);
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note));
if (this.noteId && (label || relation)) {
// probably incorrect event
// calling this.refresh() is not enough since the event needs to be propagated to children as well
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
}
}
}
beforeUnloadEvent() {
return this.spacedUpdate.isAllSavedAndTriggerUpdate();
}
readOnlyTemporarilyDisabledEvent({ noteContext }: EventData<"readOnlyTemporarilyDisabled">) {
if (this.isNoteContext(noteContext.ntxId)) {
this.refresh();
}
}
async executeInActiveNoteDetailWidgetEvent({ callback }: EventData<"executeInActiveNoteDetailWidget">) {
if (!this.isActiveNoteContext()) {
return;
}
await this.initialized;
callback(this);
}
async cutIntoNoteCommand() {
const note = appContext.tabManager.getActiveContextNote();
if (!note) {
return;
}
// without await as this otherwise causes deadlock through component mutex
const parentNotePath = appContext.tabManager.getActiveContextNotePath();
if (this.noteContext && parentNotePath) {
noteCreateService.createNote(parentNotePath, {
isProtected: note.isProtected,
saveSelection: true,
textEditor: await this.noteContext.getTextEditor()
});
}
}
// used by cutToNote in CKEditor build
async saveNoteDetailNowCommand() {
await this.spacedUpdate.updateNowIfNecessary();
}
renderActiveNoteEvent() {
if (this.noteContext?.isActive()) {
this.refresh();
}
}
async executeWithTypeWidgetEvent({ resolve, ntxId }: EventData<"executeWithTypeWidget">) {
if (!this.isNoteContext(ntxId)) {
return;
}
await this.initialized;
await this.getWidgetType();
resolve(this.getTypeWidget());
}
}

View File

@@ -1,671 +0,0 @@
import server from "../services/server.js";
import attributeService from "../services/attributes.js";
import hoistedNoteService from "../services/hoisted_note.js";
import appContext, { type EventData } from "../components/app_context.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import linkContextMenuService from "../menus/link_context_menu.js";
import utils from "../services/utils.js";
import { t } from "../services/i18n.js";
import type ForceGraph from "force-graph";
import type { GraphData, LinkObject, NodeObject } from "force-graph";
import type FNote from "../entities/fnote.js";
const esc = utils.escapeHtml;
const TPL = /*html*/`<div class="note-map-widget">
<style>
.note-detail-note-map {
height: 100%;
overflow: hidden;
}
/* Style Ui Element to Drag Nodes */
.fixnodes-type-switcher {
display: flex;
align-items: center;
z-index: 10; /* should be below dropdown (note actions) */
border-radius: .2rem;
}
.fixnodes-type-switcher button.toggled {
background: var(--active-item-background-color);
color: var(--active-item-text-color);
}
/* Start of styling the slider */
.fixnodes-type-switcher input[type="range"] {
/* removing default appearance */
-webkit-appearance: none;
appearance: none;
margin-inline-start: 15px;
width: 150px;
}
/* Changing slider tracker */
.fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background-color: var(--main-border-color);
border-radius: 4px;
}
/* Changing Slider Thumb */
.fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb {
/* removing default appearance */
-webkit-appearance: none;
appearance: none;
/* creating a custom design */
height: 15px;
width: 15px;
margin-top:-5px;
background-color: var(--accented-background-color);
border: 1px solid var(--main-text-color);
border-radius: 50%;
}
.fixnodes-type-switcher input[type="range"]::-moz-range-track {
background-color: var(--main-border-color);
border-radius: 4px;
}
.fixnodes-type-switcher input[type="range"]::-moz-range-thumb {
background-color: var(--accented-background-color);
border-color: var(--main-text-color);
height: 10px;
width: 10px;
}
/* End of styling the slider */
</style>
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
<button type="button" class="btn bx bx-network-chart tn-tool-button" title="${t("note-map.button-link-map")}" data-type="link"></button>
<button type="button" class="btn bx bx-sitemap tn-tool-button" title="${t("note-map.button-tree-map")}" data-type="tree"></button>
</div>
<! UI for dragging Notes and link force >
<div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
<button type="button" data-toggle="button" class="btn bx bx-lock-alt tn-tool-button" title="${t("note_map.fix-nodes")}" data-type="moveable"></button>
<input type="range" class="slider" min="1" title="${t("note_map.link-distance")}" max="100" value="40" >
</div>
<div class="style-resolver"></div>
<div class="note-map-container"></div>
</div>`;
type WidgetMode = "type" | "ribbon";
type MapType = "tree" | "link";
type Data = GraphData<NodeObject, LinkObject<NodeObject>>;
interface Node extends NodeObject {
id: string;
name: string;
type: string;
color: string;
}
interface Link extends LinkObject<NodeObject> {
id: string;
name: string;
x: number;
y: number;
source: Node;
target: Node;
}
interface NotesAndRelationsData {
nodes: Node[];
links: {
id: string;
source: string;
target: string;
name: string;
}[];
}
// Replace
interface ResponseLink {
key: string;
sourceNoteId: string;
targetNoteId: string;
name: string;
}
interface PostNotesMapResponse {
notes: string[];
links: ResponseLink[];
noteIdToDescendantCountMap: Record<string, number>;
}
interface GroupedLink {
id: string;
sourceNoteId: string;
targetNoteId: string;
names: string[];
}
interface CssData {
fontFamily: string;
textColor: string;
mutedTextColor: string;
}
export default class NoteMapWidget extends NoteContextAwareWidget {
private fixNodes: boolean;
private widgetMode: WidgetMode;
private mapType?: MapType;
private cssData!: CssData;
private themeStyle!: string;
private $container!: JQuery<HTMLElement>;
private $styleResolver!: JQuery<HTMLElement>;
private $fixNodesButton!: JQuery<HTMLElement>;
graph!: ForceGraph;
private noteIdToSizeMap!: Record<string, number>;
private zoomLevel!: number;
private nodes!: Node[];
constructor(widgetMode: WidgetMode) {
super();
this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code
this.widgetMode = widgetMode; // 'type' or 'ribbon'
}
doRender() {
this.$widget = $(TPL);
const documentStyle = window.getComputedStyle(document.documentElement);
this.themeStyle = documentStyle.getPropertyValue("--theme-style")?.trim();
this.$container = this.$widget.find(".note-map-container");
this.$styleResolver = this.$widget.find(".style-resolver");
this.$fixNodesButton = this.$widget.find(".fixnodes-type-switcher > button");
new ResizeObserver(() => this.setDimensions()).observe(this.$container[0]);
this.$widget.find(".map-type-switcher button").on("click", async (e) => {
const type = $(e.target).closest("button").attr("data-type");
await attributeService.setLabel(this.noteId ?? "", "mapType", type);
});
// Reading the status of the Drag nodes Ui element. Changing it´s color when activated.
// Reading Force value of the link distance.
this.$fixNodesButton.on("click", async (event) => {
this.fixNodes = !this.fixNodes;
this.$fixNodesButton.toggleClass("toggled", this.fixNodes);
});
super.doRender();
}
setDimensions() {
if (!this.graph) {
// no graph has been even rendered
return;
}
const $parent = this.$widget.parent();
this.graph
.height($parent.height() || 0)
.width($parent.width() || 0);
}
async refreshWithNote(note: FNote) {
this.$widget.show();
this.cssData = {
fontFamily: this.$container.css("font-family"),
textColor: this.rgb2hex(this.$container.css("color")),
mutedTextColor: this.rgb2hex(this.$styleResolver.css("color"))
};
this.mapType = note.getLabelValue("mapType") === "tree" ? "tree" : "link";
//variables for the hover effekt. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself
let hoverNode: NodeObject | null = null;
const highlightLinks = new Set();
const neighbours = new Set();
const ForceGraph = (await import("force-graph")).default;
this.graph = new ForceGraph(this.$container[0])
.width(this.$container.width() || 0)
.height(this.$container.height() || 0)
.onZoom((zoom) => this.setZoomLevel(zoom.k))
.d3AlphaDecay(0.01)
.d3VelocityDecay(0.08)
//Code to fixate nodes when dragged
.onNodeDragEnd((node) => {
if (this.fixNodes) {
node.fx = node.x;
node.fy = node.y;
} else {
node.fx = undefined;
node.fy = undefined;
}
})
//check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted
.onNodeHover((node) => {
hoverNode = node || null;
highlightLinks.clear();
})
// set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks
.linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
.linkColor((link) => (highlightLinks.has(link) ? "white" : this.cssData.mutedTextColor))
.linkDirectionalArrowLength(4)
.linkDirectionalArrowRelPos(0.95)
// main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
.nodeCanvasObject((_node, ctx) => {
const node = _node as Node;
if (hoverNode == node) {
//paint only hovered node
this.paintNode(node, "#661822", ctx);
neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
for (const _link of data.links) {
const link = _link as unknown as Link;
//check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes
if (link.source.id == node.id || link.target.id == node.id) {
neighbours.add(link.source);
neighbours.add(link.target);
highlightLinks.add(link);
neighbours.delete(node);
}
}
} else if (neighbours.has(node) && hoverNode != null) {
//paint neighbours
this.paintNode(node, "#9d6363", ctx);
} else {
this.paintNode(node, this.getColorForNode(node), ctx); //paint rest of nodes in canvas
}
})
.nodePointerAreaPaint((node, _, ctx) => this.paintNode(node as Node, this.getColorForNode(node as Node), ctx))
.nodePointerAreaPaint((node, color, ctx) => {
if (!node.id) {
return;
}
ctx.fillStyle = color;
ctx.beginPath();
if (node.x && node.y) {
ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
}
ctx.fill();
})
.nodeLabel((node) => esc((node as Node).name))
.maxZoom(7)
.warmupTicks(30)
.onNodeClick((node) => {
if (node.id) {
appContext.tabManager.getActiveContext()?.setNote((node as Node).id);
}
})
.onNodeRightClick((node, e) => {
if (node.id) {
linkContextMenuService.openContextMenu((node as Node).id, e);
}
});
if (this.mapType === "link") {
this.graph
.linkLabel((l) => `${esc((l as Link).source.name)} - <strong>${esc((l as Link).name)}</strong> - ${esc((l as Link).target.name)}`)
.linkCanvasObject((link, ctx) => this.paintLink(link as Link, ctx))
.linkCanvasObjectMode(() => "after");
}
const mapRootNoteId = this.getMapRootNoteId();
const labelValues = (name: string) => this.note?.getLabels(name).map(l => l.value) ?? [];
const excludeRelations = labelValues("mapExcludeRelation");
const includeRelations = labelValues("mapIncludeRelation");
const data = await this.loadNotesAndRelations(mapRootNoteId, excludeRelations, includeRelations);
const nodeLinkRatio = data.nodes.length / data.links.length;
const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
const charge = -20 / magnifiedRatio;
const boundedCharge = Math.min(-3, charge);
let distancevalue = 40; // default value for the link force of the nodes
this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => {
distancevalue = parseInt(e.target.closest("input")?.value ?? "0");
this.graph.d3Force("link")?.distance(distancevalue);
this.renderData(data);
});
this.graph.d3Force("center")?.strength(0.2);
this.graph.d3Force("charge")?.strength(boundedCharge);
this.graph.d3Force("charge")?.distanceMax(1000);
this.renderData(data);
}
getMapRootNoteId(): string {
if (this.noteId && this.widgetMode === "ribbon") {
return this.noteId;
}
let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId");
if (mapRootNoteId === "hoisted") {
mapRootNoteId = hoistedNoteService.getHoistedNoteId();
} else if (!mapRootNoteId) {
mapRootNoteId = appContext.tabManager.getActiveContext()?.parentNoteId;
}
return mapRootNoteId ?? "";
}
getColorForNode(node: Node) {
if (node.color) {
return node.color;
} else if (this.widgetMode === "ribbon" && node.id === this.noteId) {
return "red"; // subtree root mark as red
} else {
return this.generateColorFromString(node.type);
}
}
generateColorFromString(str: string) {
if (this.themeStyle === "dark") {
str = `0${str}`; // magic lightning modifier
}
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.substr(-2);
}
return color;
}
rgb2hex(rgb: string) {
return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
.slice(1)
.map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
.join("")}`;
}
setZoomLevel(level: number) {
this.zoomLevel = level;
}
paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) {
const { x, y } = node;
if (!x || !y) {
return;
}
const size = this.noteIdToSizeMap[node.id];
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false);
ctx.fill();
const toRender = this.zoomLevel > 2 || (this.zoomLevel > 1 && size > 6) || (this.zoomLevel > 0.3 && size > 10);
if (!toRender) {
return;
}
ctx.fillStyle = this.cssData.textColor;
ctx.font = `${size}px ${this.cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let title = node.name;
if (title.length > 15) {
title = `${title.substr(0, 15)}...`;
}
ctx.fillText(title, x, y + Math.round(size * 1.5));
}
paintLink(link: Link, ctx: CanvasRenderingContext2D) {
if (this.zoomLevel < 5) {
return;
}
ctx.font = `3px ${this.cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = this.cssData.mutedTextColor;
const { source, target } = link;
if (typeof source !== "object" || typeof target !== "object") {
return;
}
if (source.x && source.y && target.x && target.y) {
const x = (source.x + target.x) / 2;
const y = (source.y + target.y) / 2;
ctx.save();
ctx.translate(x, y);
const deltaY = source.y - target.y;
const deltaX = source.x - target.x;
let angle = Math.atan2(deltaY, deltaX);
let moveY = 2;
if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
angle += Math.PI;
moveY = -2;
}
ctx.rotate(angle);
ctx.fillText(link.name, 0, moveY);
}
ctx.restore();
}
async loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[]): Promise<NotesAndRelationsData> {
const resp = await server.post<PostNotesMapResponse>(`note-map/${mapRootNoteId}/${this.mapType}`, {
excludeRelations, includeRelations
});
this.calculateNodeSizes(resp);
const links = this.getGroupedLinks(resp.links);
this.nodes = resp.notes.map(([noteId, title, type, color]) => ({
id: noteId,
name: title,
type: type,
color: color
}));
return {
nodes: this.nodes,
links: links.map((link) => ({
id: `${link.sourceNoteId}-${link.targetNoteId}`,
source: link.sourceNoteId,
target: link.targetNoteId,
name: link.names.join(", ")
}))
};
}
getGroupedLinks(links: ResponseLink[]): GroupedLink[] {
const linksGroupedBySourceTarget: Record<string, GroupedLink> = {};
for (const link of links) {
const key = `${link.sourceNoteId}-${link.targetNoteId}`;
if (key in linksGroupedBySourceTarget) {
if (!linksGroupedBySourceTarget[key].names.includes(link.name)) {
linksGroupedBySourceTarget[key].names.push(link.name);
}
} else {
linksGroupedBySourceTarget[key] = {
id: key,
sourceNoteId: link.sourceNoteId,
targetNoteId: link.targetNoteId,
names: [link.name]
};
}
}
return Object.values(linksGroupedBySourceTarget);
}
calculateNodeSizes(resp: PostNotesMapResponse) {
this.noteIdToSizeMap = {};
if (this.mapType === "tree") {
const { noteIdToDescendantCountMap } = resp;
for (const noteId in noteIdToDescendantCountMap) {
this.noteIdToSizeMap[noteId] = 4;
const count = noteIdToDescendantCountMap[noteId];
if (count > 0) {
this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
}
}
} else if (this.mapType === "link") {
const noteIdToLinkCount: Record<string, number> = {};
for (const link of resp.links) {
noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0);
}
for (const [noteId] of resp.notes) {
this.noteIdToSizeMap[noteId] = 4;
if (noteId in noteIdToLinkCount) {
this.noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15);
}
}
}
}
renderData(data: Data) {
this.graph.graphData(data);
if (this.widgetMode === "ribbon" && this.note?.type !== "search") {
setTimeout(() => {
this.setDimensions();
const subGraphNoteIds = this.getSubGraphConnectedToCurrentNote(data);
this.graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id));
if (subGraphNoteIds.size < 30) {
this.graph.d3VelocityDecay(0.4);
}
}, 1000);
} else {
if (data.nodes.length > 1) {
setTimeout(() => {
this.setDimensions();
const noteIdsWithLinks = this.getNoteIdsWithLinks(data);
if (noteIdsWithLinks.size > 0) {
this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
}
if (noteIdsWithLinks.size < 30) {
this.graph.d3VelocityDecay(0.4);
}
}, 1000);
}
}
}
getNoteIdsWithLinks(data: Data) {
const noteIds = new Set<string | number>();
for (const link of data.links) {
if (typeof link.source === "object" && link.source.id) {
noteIds.add(link.source.id);
}
if (typeof link.target === "object" && link.target.id) {
noteIds.add(link.target.id);
}
}
return noteIds;
}
getSubGraphConnectedToCurrentNote(data: Data) {
function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
const map: Record<string | number, LinkObject<NodeObject>[]> = {};
for (const link of links) {
if (typeof link[type] !== "object") {
continue;
}
const key = link[type].id;
if (key) {
map[key] = map[key] || [];
map[key].push(link);
}
}
return map;
}
const linksBySource = getGroupedLinks(data.links, "source");
const linksByTarget = getGroupedLinks(data.links, "target");
const subGraphNoteIds = new Set();
function traverseGraph(noteId?: string | number) {
if (!noteId || subGraphNoteIds.has(noteId)) {
return;
}
subGraphNoteIds.add(noteId);
for (const link of linksBySource[noteId] || []) {
if (typeof link.target === "object") {
traverseGraph(link.target?.id);
}
}
for (const link of linksByTarget[noteId] || []) {
if (typeof link.source === "object") {
traverseGraph(link.source?.id);
}
}
}
traverseGraph(this.noteId);
return subGraphNoteIds;
}
cleanup() {
this.$container.html("");
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows(this.componentId)
.find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,57 @@
.note-detail-note-map {
height: 100%;
overflow: hidden;
}
/* Style Ui Element to Drag Nodes */
.fixnodes-type-switcher {
display: flex;
align-items: center;
z-index: 10; /* should be below dropdown (note actions) */
border-radius: .2rem;
}
/* Start of styling the slider */
.fixnodes-type-switcher input[type="range"] {
/* removing default appearance */
-webkit-appearance: none;
appearance: none;
margin-inline-start: 15px;
width: 150px;
}
/* Changing slider tracker */
.fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background-color: var(--main-border-color);
border-radius: 4px;
}
/* Changing Slider Thumb */
.fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb {
/* removing default appearance */
-webkit-appearance: none;
appearance: none;
/* creating a custom design */
height: 15px;
width: 15px;
margin-top:-5px;
background-color: var(--accented-background-color);
border: 1px solid var(--main-text-color);
border-radius: 50%;
}
.fixnodes-type-switcher input[type="range"]::-moz-range-track {
background-color: var(--main-border-color);
border-radius: 4px;
}
.fixnodes-type-switcher input[type="range"]::-moz-range-thumb {
background-color: var(--accented-background-color);
border-color: var(--main-text-color);
height: 10px;
width: 10px;
}
/* End of styling the slider */

View File

@@ -0,0 +1,174 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import "./NoteMap.css";
import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
import { RefObject } from "preact";
import FNote from "../../entities/fnote";
import { useElementSize, useNoteLabel } from "../react/hooks";
import ForceGraph from "force-graph";
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
import { CssData, setupRendering } from "./rendering";
import ActionButton from "../react/ActionButton";
import { t } from "../../services/i18n";
import link_context_menu from "../../menus/link_context_menu";
import appContext from "../../components/app_context";
import Slider from "../react/Slider";
import hoisted_note from "../../services/hoisted_note";
interface NoteMapProps {
note: FNote;
widgetMode: NoteMapWidgetMode;
parentRef: RefObject<HTMLElement>;
}
export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const styleResolverRef = useRef<HTMLDivElement>(null);
const [ mapTypeRaw, setMapType ] = useNoteLabel(note, "mapType");
const [ mapRootIdLabel ] = useNoteLabel(note, "mapRootNoteId");
const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link";
const graphRef = useRef<ForceGraph<NoteMapNodeObject, NoteMapLinkObject>>();
const containerSize = useElementSize(parentRef);
const [ fixNodes, setFixNodes ] = useState(false);
const [ linkDistance, setLinkDistance ] = useState(40);
const notesAndRelationsRef = useRef<NotesAndRelationsData>();
const mapRootId = useMemo(() => {
if (note.noteId && widgetMode === "ribbon") {
return note.noteId;
} else if (mapRootIdLabel === "hoisted") {
return hoisted_note.getHoistedNoteId();
} else if (mapRootIdLabel) {
return mapRootIdLabel;
} else {
return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
}
}, [ note ]);
// Build the note graph instance.
useEffect(() => {
const container = containerRef.current;
if (!container || !mapRootId) return;
const graph = new ForceGraph<NoteMapNodeObject, NoteMapLinkObject>(container);
graphRef.current = graph;
const labelValues = (name: string) => note.getLabels(name).map(l => l.value) ?? [];
const excludeRelations = labelValues("mapExcludeRelation");
const includeRelations = labelValues("mapIncludeRelation");
loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => {
if (!containerRef.current || !styleResolverRef.current) return;
const cssData = getCssData(containerRef.current, styleResolverRef.current);
// Configure rendering properties.
setupRendering(graph, {
note,
noteId: note.noteId,
noteIdToSizeMap: notesAndRelations.noteIdToSizeMap,
cssData,
notesAndRelations,
themeStyle: getThemeStyle(),
widgetMode,
mapType
});
// Interaction
graph
.onNodeClick((node) => {
if (!node.id) return;
appContext.tabManager.getActiveContext()?.setNote(node.id);
})
.onNodeRightClick((node, e) => {
if (!node.id) return;
link_context_menu.openContextMenu(node.id, e);
});
// Set data
graph.graphData(notesAndRelations);
notesAndRelationsRef.current = notesAndRelations;
});
return () => container.replaceChildren();
}, [ note, mapType ]);
useEffect(() => {
if (!graphRef.current || !notesAndRelationsRef.current) return;
graphRef.current.d3Force("link")?.distance(linkDistance);
graphRef.current.graphData(notesAndRelationsRef.current);
}, [ linkDistance ]);
// React to container size
useEffect(() => {
if (!containerSize || !graphRef.current) return;
graphRef.current.width(containerSize.width).height(containerSize.height);
}, [ containerSize?.width, containerSize?.height ]);
// Fixing nodes when dragged.
useEffect(() => {
graphRef.current?.onNodeDragEnd((node) => {
if (fixNodes) {
node.fx = node.x;
node.fy = node.y;
} else {
node.fx = undefined;
node.fy = undefined;
}
})
}, [ fixNodes ]);
return (
<div className="note-map-widget">
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
<MapTypeSwitcher type="link" icon="bx bx-network-chart" text={t("note-map.button-link-map")} currentMapType={mapType} setMapType={setMapType} />
<MapTypeSwitcher type="tree" icon="bx bx-sitemap" text={t("note-map.button-tree-map")} currentMapType={mapType} setMapType={setMapType} />
</div>
<div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
<ActionButton
icon="bx bx-lock-alt"
text={t("note_map.fix-nodes")}
className={fixNodes ? "active" : ""}
onClick={() => setFixNodes(!fixNodes)}
frame
/>
<Slider
min={1} max={100}
value={linkDistance} onChange={setLinkDistance}
title={t("note_map.link-distance")}
/>
</div>
<div ref={styleResolverRef} class="style-resolver" />
<div ref={containerRef} className="note-map-container" />
</div>
);
}
function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: {
icon: string;
text: string;
type: MapType;
currentMapType: MapType;
setMapType: (type: MapType) => void;
}) {
return (
<ActionButton
icon={icon} text={text}
active={currentMapType === type}
onClick={() => setMapType(type)}
frame
/>
)
}
function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData {
const containerStyle = window.getComputedStyle(container);
const styleResolverStyle = window.getComputedStyle(styleResolver);
return {
fontFamily: containerStyle.fontFamily,
textColor: rgb2hex(containerStyle.color),
mutedTextColor: rgb2hex(styleResolverStyle.color)
}
}

View File

@@ -0,0 +1,120 @@
import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons";
import server from "../../services/server";
import { LinkObject, NodeObject } from "force-graph";
type MapType = "tree" | "link";
interface GroupedLink {
id: string;
sourceNoteId: string;
targetNoteId: string;
names: string[];
}
export interface NoteMapNodeObject extends NodeObject {
id: string;
name: string;
type: string;
color: string;
}
export interface NoteMapLinkObject extends LinkObject<NoteMapNodeObject> {
id: string;
name: string;
x?: number;
y?: number;
}
export interface NotesAndRelationsData {
nodes: NoteMapNodeObject[];
links: {
id: string;
source: string | NoteMapNodeObject;
target: string | NoteMapNodeObject;
name: string;
}[];
noteIdToSizeMap: Record<string, number>;
}
export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise<NotesAndRelationsData> {
const resp = await server.post<NoteMapPostResponse>(`note-map/${mapRootNoteId}/${mapType}`, {
excludeRelations, includeRelations
});
const noteIdToSizeMap = calculateNodeSizes(resp, mapType);
const links = getGroupedLinks(resp.links);
const nodes = resp.notes.map(([noteId, title, type, color]) => ({
id: noteId,
name: title,
type: type,
color: color
}));
return {
noteIdToSizeMap,
nodes,
links: links.map((link) => ({
id: `${link.sourceNoteId}-${link.targetNoteId}`,
source: link.sourceNoteId,
target: link.targetNoteId,
name: link.names.join(", ")
}))
};
}
function calculateNodeSizes(resp: NoteMapPostResponse, mapType: MapType) {
const noteIdToSizeMap: Record<string, number> = {};
if (mapType === "tree") {
const { noteIdToDescendantCountMap } = resp;
for (const noteId in noteIdToDescendantCountMap) {
noteIdToSizeMap[noteId] = 4;
const count = noteIdToDescendantCountMap[noteId];
if (count > 0) {
noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
}
}
} else if (mapType === "link") {
const noteIdToLinkCount: Record<string, number> = {};
for (const link of resp.links) {
noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0);
}
for (const [noteId] of resp.notes) {
noteIdToSizeMap[noteId] = 4;
if (noteId in noteIdToLinkCount) {
noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15);
}
}
}
return noteIdToSizeMap;
}
function getGroupedLinks(links: NoteMapLink[]): GroupedLink[] {
const linksGroupedBySourceTarget: Record<string, GroupedLink> = {};
for (const link of links) {
const key = `${link.sourceNoteId}-${link.targetNoteId}`;
if (key in linksGroupedBySourceTarget) {
if (!linksGroupedBySourceTarget[key].names.includes(link.name)) {
linksGroupedBySourceTarget[key].names.push(link.name);
}
} else {
linksGroupedBySourceTarget[key] = {
id: key,
sourceNoteId: link.sourceNoteId,
targetNoteId: link.targetNoteId,
names: [link.name]
};
}
}
return Object.values(linksGroupedBySourceTarget);
}

View File

@@ -0,0 +1,282 @@
import type ForceGraph from "force-graph";
import { NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
import { LinkObject, NodeObject } from "force-graph";
import { generateColorFromString, MapType, NoteMapWidgetMode } from "./utils";
import { escapeHtml } from "../../services/utils";
import FNote from "../../entities/fnote";
export interface CssData {
fontFamily: string;
textColor: string;
mutedTextColor: string;
}
interface RenderData {
note: FNote;
noteIdToSizeMap: Record<string, number>;
cssData: CssData;
noteId: string;
themeStyle: "light" | "dark";
widgetMode: NoteMapWidgetMode;
notesAndRelations: NotesAndRelationsData;
mapType: MapType;
}
export function setupRendering(graph: ForceGraph<NoteMapNodeObject, NoteMapLinkObject>, { note, noteId, themeStyle, widgetMode, noteIdToSizeMap, notesAndRelations, cssData, mapType }: RenderData) {
// variables for the hover effect. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself
const neighbours = new Set();
const highlightLinks = new Set();
let hoverNode: NodeObject | null = null;
let zoomLevel: number;
function getColorForNode(node: NoteMapNodeObject) {
if (node.color) {
return node.color;
} else if (widgetMode === "ribbon" && node.id === noteId) {
return "red"; // subtree root mark as red
} else {
return generateColorFromString(node.type, themeStyle);
}
}
function paintNode(node: NoteMapNodeObject, color: string, ctx: CanvasRenderingContext2D) {
const { x, y } = node;
if (!x || !y) {
return;
}
const size = noteIdToSizeMap[node.id];
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false);
ctx.fill();
const toRender = zoomLevel > 2 || (zoomLevel > 1 && size > 6) || (zoomLevel > 0.3 && size > 10);
if (!toRender) {
return;
}
ctx.fillStyle = cssData.textColor;
ctx.font = `${size}px ${cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let title = node.name;
if (title.length > 15) {
title = `${title.substr(0, 15)}...`;
}
ctx.fillText(title, x, y + Math.round(size * 1.5));
}
function paintLink(link: NoteMapLinkObject, ctx: CanvasRenderingContext2D) {
if (zoomLevel < 5) {
return;
}
ctx.font = `3px ${cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = cssData.mutedTextColor;
const { source, target } = link;
if (typeof source !== "object" || typeof target !== "object") {
return;
}
if (source.x && source.y && target.x && target.y) {
const x = (source.x + target.x) / 2;
const y = (source.y + target.y) / 2;
ctx.save();
ctx.translate(x, y);
const deltaY = source.y - target.y;
const deltaX = source.x - target.x;
let angle = Math.atan2(deltaY, deltaX);
let moveY = 2;
if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
angle += Math.PI;
moveY = -2;
}
ctx.rotate(angle);
ctx.fillText(link.name, 0, moveY);
}
ctx.restore();
}
// main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
graph
.d3AlphaDecay(0.01)
.d3VelocityDecay(0.08)
.maxZoom(7)
.warmupTicks(30)
.nodeCanvasObject((node, ctx) => {
if (hoverNode == node) {
//paint only hovered node
paintNode(node, "#661822", ctx);
neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
for (const link of notesAndRelations.links) {
const { source, target } = link;
if (typeof source !== "object" || typeof target !== "object") continue;
//check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes
if (source.id == node.id || target.id == node.id) {
neighbours.add(link.source);
neighbours.add(link.target);
highlightLinks.add(link);
neighbours.delete(node);
}
}
} else if (neighbours.has(node) && hoverNode != null) {
//paint neighbours
paintNode(node, "#9d6363", ctx);
} else {
paintNode(node, getColorForNode(node), ctx); //paint rest of nodes in canvas
}
})
//check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted
.onNodeHover((node) => {
hoverNode = node || null;
highlightLinks.clear();
})
.nodePointerAreaPaint((node, _, ctx) => paintNode(node, getColorForNode(node), ctx))
.nodePointerAreaPaint((node, color, ctx) => {
if (!node.id) {
return;
}
ctx.fillStyle = color;
ctx.beginPath();
if (node.x && node.y) {
ctx.arc(node.x, node.y, noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
}
ctx.fill();
})
.nodeLabel((node) => escapeHtml(node.name))
.onZoom((zoom) => zoomLevel = zoom.k);
// set link width to immitate a highlight effect. Checking the condition if any links are saved in the previous defined set highlightlinks
graph
.linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
.linkColor((link) => (highlightLinks.has(link) ? cssData.textColor : cssData.mutedTextColor))
.linkDirectionalArrowLength(4)
.linkDirectionalArrowRelPos(0.95);
// Link-specific config
if (mapType) {
graph
.linkLabel((link) => {
const { source, target } = link;
if (typeof source !== "object" || typeof target !== "object") return escapeHtml(link.name);
return `${escapeHtml(source.name)} - <strong>${escapeHtml(link.name)}</strong> - ${escapeHtml(target.name)}`;
})
.linkCanvasObject((link, ctx) => paintLink(link, ctx))
.linkCanvasObjectMode(() => "after");
}
// Forces
const nodeLinkRatio = notesAndRelations.nodes.length / notesAndRelations.links.length;
const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
const charge = -20 / magnifiedRatio;
const boundedCharge = Math.min(-3, charge);
graph.d3Force("center")?.strength(0.2);
graph.d3Force("charge")?.strength(boundedCharge);
graph.d3Force("charge")?.distanceMax(1000);
// Zoom to notes
if (widgetMode === "ribbon" && note?.type !== "search") {
setTimeout(() => {
const subGraphNoteIds = getSubGraphConnectedToCurrentNote(noteId, notesAndRelations);
graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id));
if (subGraphNoteIds.size < 30) {
graph.d3VelocityDecay(0.4);
}
}, 1000);
} else {
if (notesAndRelations.nodes.length > 1) {
setTimeout(() => {
const noteIdsWithLinks = getNoteIdsWithLinks(notesAndRelations);
if (noteIdsWithLinks.size > 0) {
graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
}
if (noteIdsWithLinks.size < 30) {
graph.d3VelocityDecay(0.4);
}
}, 1000);
}
}
}
function getNoteIdsWithLinks(data: NotesAndRelationsData) {
const noteIds = new Set<string | number>();
for (const link of data.links) {
if (typeof link.source === "object" && link.source.id) {
noteIds.add(link.source.id);
}
if (typeof link.target === "object" && link.target.id) {
noteIds.add(link.target.id);
}
}
return noteIds;
}
function getSubGraphConnectedToCurrentNote(noteId: string, data: NotesAndRelationsData) {
function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
const map: Record<string | number, LinkObject<NodeObject>[]> = {};
for (const link of links) {
if (typeof link[type] !== "object") {
continue;
}
const key = link[type].id;
if (key) {
map[key] = map[key] || [];
map[key].push(link);
}
}
return map;
}
const linksBySource = getGroupedLinks(data.links, "source");
const linksByTarget = getGroupedLinks(data.links, "target");
const subGraphNoteIds = new Set();
function traverseGraph(noteId?: string | number) {
if (!noteId || subGraphNoteIds.has(noteId)) {
return;
}
subGraphNoteIds.add(noteId);
for (const link of linksBySource[noteId] || []) {
if (typeof link.target === "object") {
traverseGraph(link.target?.id);
}
}
for (const link of linksByTarget[noteId] || []) {
if (typeof link.source === "object") {
traverseGraph(link.source?.id);
}
}
}
traverseGraph(noteId);
return subGraphNoteIds;
}

View File

@@ -0,0 +1,33 @@
export type NoteMapWidgetMode = "ribbon" | "hoisted" | "type";
export type MapType = "tree" | "link";
export function rgb2hex(rgb: string) {
return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
.slice(1)
.map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
.join("")}`;
}
export function generateColorFromString(str: string, themeStyle: "light" | "dark") {
if (themeStyle === "dark") {
str = `0${str}`; // magic lightning modifier
}
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.substr(-2);
}
return color;
}
export function getThemeStyle() {
const documentStyle = window.getComputedStyle(document.documentElement);
return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
}

View File

@@ -47,7 +47,9 @@ export default function NoteTitleWidget() {
// Prevent user from navigating away if the spaced update is not done. // Prevent user from navigating away if the spaced update is not done.
useEffect(() => { useEffect(() => {
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate()); const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
appContext.addBeforeUnloadListener(listener);
return () => appContext.removeBeforeUnloadListener(listener);
}, []); }, []);
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary()); useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());

View File

@@ -0,0 +1,143 @@
/**
* @module
* Contains the definitions for all the note types supported by the application.
*/
import { NoteType } from "@triliumnext/commons";
import { VNode, type JSX } from "preact";
import { TypeWidgetProps } from "./type_widgets/type_widget";
/**
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "aiChat";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
interface NoteTypeMapping {
view: NoteTypeView;
printable?: boolean;
/** The class name to assign to the note type wrapper */
className: string;
isFullHeight?: boolean;
}
export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
empty: {
view: () => import("./type_widgets/Empty"),
className: "note-detail-empty",
printable: true
},
doc: {
view: () => import("./type_widgets/Doc"),
className: "note-detail-doc",
printable: true
},
search: {
view: () => (props: TypeWidgetProps) => <></>,
className: "note-detail-none",
printable: true
},
protectedSession: {
view: () => import("./type_widgets/ProtectedSession"),
className: "protected-session-password-component"
},
book: {
view: () => import("./type_widgets/Book"),
className: "note-detail-book",
printable: true,
},
contentWidget: {
view: () => import("./type_widgets/ContentWidget"),
className: "note-detail-content-widget",
printable: true
},
webView: {
view: () => import("./type_widgets/WebView"),
className: "note-detail-web-view",
printable: true,
isFullHeight: true
},
file: {
view: () => import("./type_widgets/File"),
className: "note-detail-file",
printable: true,
isFullHeight: true
},
image: {
view: () => import("./type_widgets/Image"),
className: "note-detail-image",
printable: true
},
readOnlyCode: {
view: async () => (await import("./type_widgets/code/Code")).ReadOnlyCode,
className: "note-detail-readonly-code",
printable: true
},
editableCode: {
view: async () => (await import("./type_widgets/code/Code")).EditableCode,
className: "note-detail-code",
printable: true
},
mermaid: {
view: () => import("./type_widgets/Mermaid"),
className: "note-detail-mermaid",
printable: true,
isFullHeight: true
},
mindMap: {
view: () => import("./type_widgets/MindMap"),
className: "note-detail-mind-map",
printable: true,
isFullHeight: true
},
attachmentList: {
view: async () => (await import("./type_widgets/Attachment")).AttachmentList,
className: "attachment-list",
printable: true
},
attachmentDetail: {
view: async () => (await import("./type_widgets/Attachment")).AttachmentDetail,
className: "attachment-detail",
printable: true
},
readOnlyText: {
view: () => import("./type_widgets/text/ReadOnlyText"),
className: "note-detail-readonly-text"
},
editableText: {
view: () => import("./type_widgets/text/EditableText"),
className: "note-detail-editable-text",
printable: true
},
render: {
view: () => import("./type_widgets/Render"),
className: "note-detail-render",
printable: true
},
canvas: {
view: () => import("./type_widgets/canvas/Canvas"),
className: "note-detail-canvas",
printable: true,
isFullHeight: true
},
relationMap: {
view: () => import("./type_widgets/relation_map/RelationMap"),
className: "note-detail-relation-map",
printable: true,
isFullHeight: true
},
noteMap: {
view: () => import("./type_widgets/NoteMap"),
className: "note-detail-note-map",
printable: true,
isFullHeight: true
},
aiChat: {
view: () => import("./type_widgets/AiChat"),
className: "ai-chat-widget-container",
isFullHeight: true
}
};

View File

@@ -12,102 +12,6 @@ import type { Attribute } from "../services/attribute_parser.js";
import type FAttribute from "../entities/fattribute.js"; import type FAttribute from "../entities/fattribute.js";
import type { EventData } from "../components/app_context.js"; import type { EventData } from "../components/app_context.js";
const TPL = /*html*/`
<div class="promoted-attributes-widget">
<style>
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.promoted-attributes-container {
margin: 0 1.5em;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-inline-start: 10px;
display: flex;
min-height: 40px;
}
.promoted-attribute-cell strong {
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;
inset-inline-start: 0px;
inset-inline-end: 0;
height: 2px;
background: rgba(0, 0, 0, 0.5);
transform: rotate(45deg);
pointer-events: none;
}
</style>
<div class="promoted-attributes-container"></div>
</div>`;
// TODO: Deduplicate // TODO: Deduplicate
interface AttributeResult { interface AttributeResult {
attributeId: string; attributeId: string;
@@ -117,115 +21,17 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
private $container!: JQuery<HTMLElement>; private $container!: JQuery<HTMLElement>;
get name() {
return "promotedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabPromotedAttributes";
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $("");
this.contentSized(); this.contentSized();
this.$container = this.$widget.find(".promoted-attributes-container"); this.$container = this.$widget.find(".promoted-attributes-container");
} }
getTitle(note: FNote) {
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
if (promotedDefAttrs.length === 0) {
return { show: false };
}
return {
show: true,
activate: options.is("promotedAttributesOpenInRibbon"),
title: t("promoted_attributes.promoted_attributes"),
icon: "bx bx-table"
};
}
async refreshWithNote(note: FNote) {
this.$container.empty();
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
this.toggleInt(false);
return;
}
const $cells: JQuery<HTMLElement>[] = [];
for (const definitionAttr of promotedDefAttrs) {
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
if ($cell) {
$cells.push($cell);
}
}
}
// we replace the whole content in one step, so there can't be any race conditions
// (previously we saw promoted attributes doubling)
this.$container.empty().append(...$cells);
this.toggleInt(true);
}
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) { async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
const definition = definitionAttr.getDefinition(); // .on("change", (event) => this.promotedAttributeChanged(event));
const id = `value-${valueAttr.attributeId}`;
const $input = $("<input>")
.prop("tabindex", 200 + definitionAttr.position)
.prop("id", id)
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
.attr("data-attribute-type", valueAttr.type)
.attr("data-attribute-name", valueAttr.name)
.prop("value", valueAttr.value)
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
.addClass("form-control")
.addClass("promoted-attribute-input")
.on("change", (event) => this.promotedAttributeChanged(event));
const $actionCell = $("<div>");
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true"); const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
const $wrapper = $('<div class="promoted-attribute-cell">')
.append(
$("<label>")
.prop("for", id)
.text(definition.promotedAlias ?? valueName)
)
.append($("<div>").addClass("input-group").append($input))
.append($actionCell)
.append($multiplicityCell);
if (valueAttr.type === "label") { if (valueAttr.type === "label") {
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`); $wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
if (definition.labelType === "text") { if (definition.labelType === "text") {
@@ -359,8 +165,6 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
if (definition.multiplicity === "multi") { if (definition.multiplicity === "multi") {
const $addButton = $("<span>") const $addButton = $("<span>")
.addClass("bx bx-plus pointer tn-tool-button")
.prop("title", t("promoted_attributes.add_new_attribute"))
.on("click", async () => { .on("click", async () => {
const $new = await this.createPromotedAttributeCell( const $new = await this.createPromotedAttributeCell(
definitionAttr, definitionAttr,

View File

@@ -3,11 +3,12 @@ import { ComponentChildren } from "preact";
interface AdmonitionProps { interface AdmonitionProps {
type: "warning" | "note" | "caution"; type: "warning" | "note" | "caution";
children: ComponentChildren; children: ComponentChildren;
className?: string;
} }
export default function Admonition({ type, children }: AdmonitionProps) { export default function Admonition({ type, children, className }: AdmonitionProps) {
return ( return (
<div className={`admonition ${type}`} role="alert"> <div className={`admonition ${type} ${className}`} role="alert">
{children} {children}
</div> </div>
) )

View File

@@ -82,6 +82,8 @@ interface FormListItemOpts {
active?: boolean; active?: boolean;
badges?: FormListBadge[]; badges?: FormListBadge[];
disabled?: boolean; disabled?: boolean;
/** Will indicate the reason why the item is disabled via an icon, when hovered over it. */
disabledTooltip?: string;
checked?: boolean | null; checked?: boolean | null;
selected?: boolean; selected?: boolean;
container?: boolean; container?: boolean;
@@ -119,21 +121,24 @@ export function FormListItem({ className, icon, value, title, active, disabled,
<Icon icon={icon} />&nbsp; <Icon icon={icon} />&nbsp;
{description ? ( {description ? (
<div> <div>
<FormListContent description={description} {...contentProps} /> <FormListContent description={description} disabled={disabled} {...contentProps} />
</div> </div>
) : ( ) : (
<FormListContent description={description} {...contentProps} /> <FormListContent description={description} disabled={disabled} {...contentProps} />
)} )}
</li> </li>
); );
} }
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) { function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick<FormListItemOpts, "children" | "badges" | "description" | "disabled" | "disabledTooltip">) {
return <> return <>
{children} {children}
{badges && badges.map(({ className, text }) => ( {badges && badges.map(({ className, text }) => (
<span className={`badge ${className ?? ""}`}>{text}</span> <span className={`badge ${className ?? ""}`}>{text}</span>
))} ))}
{disabled && disabledTooltip && (
<span class="bx bx-info-circle disabled-tooltip" title={disabledTooltip} />
)}
{description && <div className="description">{description}</div>} {description && <div className="description">{description}</div>}
</>; </>;
} }

View File

@@ -5,16 +5,17 @@ import { openInAppHelpFromUrl } from "../../services/utils";
interface HelpButtonProps { interface HelpButtonProps {
className?: string; className?: string;
helpPage: string; helpPage: string;
title?: string;
style?: CSSProperties; style?: CSSProperties;
} }
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) { export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
return ( return (
<button <button
class={`${className ?? ""} icon-action bx bx-help-circle`} class={`${className ?? ""} icon-action bx bx-help-circle`}
type="button" type="button"
onClick={() => openInAppHelpFromUrl(helpPage)} onClick={() => openInAppHelpFromUrl(helpPage)}
title={t("open-help-page")} title={title ?? t("open-help-page")}
style={style} style={style}
/> />
); );

View File

@@ -16,8 +16,8 @@
.info-bar-subtle { .info-bar-subtle {
color: var(--muted-text-color); color: var(--muted-text-color);
background: var(--main-background-color);
border-bottom: 1px solid var(--main-border-color); border-bottom: 1px solid var(--main-border-color);
margin-block: 0; margin-block: 0;
margin-inline: 10px; padding-inline: 22px;
padding-inline: 12px;
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import link from "../../services/link"; import link, { ViewScope } from "../../services/link";
import { useImperativeSearchHighlighlighting } from "./hooks"; import { useImperativeSearchHighlighlighting } from "./hooks";
interface NoteLinkOpts { interface NoteLinkOpts {
@@ -11,18 +11,26 @@ interface NoteLinkOpts {
noPreview?: boolean; noPreview?: boolean;
noTnLink?: boolean; noTnLink?: boolean;
highlightedTokens?: string[] | null | undefined; highlightedTokens?: string[] | null | undefined;
// Override the text of the link, otherwise the note title is used.
title?: string;
viewScope?: ViewScope;
noContextMenu?: boolean;
} }
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) { export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>(); const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens); const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
useEffect(() => { useEffect(() => {
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon }) link.createLink(stringifiedNotePath, {
.then(setJqueryEl); title,
}, [ stringifiedNotePath, showNotePath ]); showNotePath,
showNoteIcon,
viewScope
}).then(setJqueryEl);
}, [ stringifiedNotePath, showNotePath, title, viewScope ]);
useEffect(() => { useEffect(() => {
if (!ref.current || !jqueryEl) return; if (!ref.current || !jqueryEl) return;
@@ -43,6 +51,10 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
$linkEl?.addClass("tn-link"); $linkEl?.addClass("tn-link");
} }
if (noContextMenu) {
$linkEl?.attr("data-no-context-menu", "true");
}
if (className) { if (className) {
$linkEl?.addClass(className); $linkEl?.addClass(className);
} }

View File

@@ -0,0 +1,20 @@
interface SliderProps {
value: number;
onChange(newValue: number);
min?: number;
max?: number;
title?: string;
}
export default function Slider({ onChange, ...restProps }: SliderProps) {
return (
<input
type="range"
className="slider"
onChange={(e) => {
onChange(e.currentTarget.valueAsNumber);
}}
{...restProps}
/>
);
}

View File

@@ -25,6 +25,7 @@ interface ButtonProps {
icon?: string; icon?: string;
click: () => void; click: () => void;
enabled?: boolean; enabled?: boolean;
backgroundColor?: string;
} }
interface SpacerProps { interface SpacerProps {
@@ -129,13 +130,14 @@ export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: S
return <></>; return <></>;
} }
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) { export function TouchBarButton({ label, icon, click, enabled, backgroundColor }: ButtonProps) {
const api = useContext(TouchBarContext); const api = useContext(TouchBarContext);
const item = useMemo(() => { const item = useMemo(() => {
if (!api) return null; if (!api) return null;
return new api.TouchBar.TouchBarButton({ return new api.TouchBar.TouchBarButton({
label, click, enabled, label, click, enabled,
icon: icon ? buildIcon(api.nativeImage, icon) : undefined icon: icon ? buildIcon(api.nativeImage, icon) : undefined,
backgroundColor
}); });
}, [ label, icon ]); }, [ label, icon ]);
@@ -171,6 +173,32 @@ export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChan
return <></>; return <></>;
} }
export function TouchBarGroup({ children }: { children: ComponentChildren }) {
const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote");
const items: TouchBarItem[] = [];
const api: TouchBarContextApi = {
TouchBar: remote.TouchBar,
nativeImage: remote.nativeImage,
addItem: (item) => {
items.push(item);
}
};
if (api) {
const item = new api.TouchBar.TouchBarGroup({
items: new api.TouchBar({ items })
});
api.addItem(item);
}
return <>
<TouchBarContext.Provider value={api}>
{children}
</TouchBarContext.Provider>
</>;
}
export function TouchBarSpacer({ size }: SpacerProps) { export function TouchBarSpacer({ size }: SpacerProps) {
const api = useContext(TouchBarContext); const api = useContext(TouchBarContext);

View File

@@ -1,12 +1,12 @@
import { CSSProperties } from "preact/compat"; import { CSSProperties } from "preact/compat";
import { DragData } from "../note_tree"; import { DragData } from "../note_tree";
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ParentComponent } from "./react_utils"; import { ParentComponent, refToJQuerySelector } from "./react_utils";
import { RefObject, VNode } from "preact"; import { RefObject, VNode } from "preact";
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
import { ViewMode } from "../../services/link"; import { ViewMode, ViewScope } from "../../services/link";
import appContext, { CommandListenerData, EventData, EventNames } from "../../components/app_context"; import appContext, { EventData, EventNames } from "../../components/app_context";
import attributes from "../../services/attributes"; import attributes from "../../services/attributes";
import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
import Component from "../../components/component"; import Component from "../../components/component";
@@ -21,6 +21,8 @@ import protected_session_holder from "../../services/protected_session_holder";
import SpacedUpdate from "../../services/spaced_update"; import SpacedUpdate from "../../services/spaced_update";
import toast, { ToastOptions } from "../../services/toast"; import toast, { ToastOptions } from "../../services/toast";
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
import server from "../../services/server";
import { removeIndividualBinding } from "../../services/shortcuts";
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) { export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
@@ -75,6 +77,66 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
return spacedUpdateRef.current; return spacedUpdateRef.current;
} }
export function useEditorSpacedUpdate({ note, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
note: FNote,
noteContext: NoteContext | null | undefined,
getData: () => Promise<object | undefined> | object | undefined,
onContentChange: (newContent: string) => void,
dataSaved?: () => void,
updateInterval?: number;
}) {
const parentComponent = useContext(ParentComponent);
const blob = useNoteBlob(note, parentComponent?.componentId);
const callback = useMemo(() => {
return async () => {
const data = await getData();
// for read only notes
if (data === undefined) return;
protected_session_holder.touchProtectedSessionIfNecessary(note);
await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId);
dataSaved?.();
}
}, [ note, getData, dataSaved ])
const spacedUpdate = useSpacedUpdate(callback);
// React to note/blob changes.
useEffect(() => {
if (!blob) return;
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
}, [ blob ]);
// React to update interval changes.
useEffect(() => {
if (!updateInterval) return;
spacedUpdate.setUpdateInterval(updateInterval);
}, [ updateInterval ]);
// Save if needed upon switching tabs.
useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => {
if (eventNoteContext.ntxId !== noteContext?.ntxId) return;
await spacedUpdate.updateNowIfNecessary();
});
// Save if needed upon tab closing.
useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => {
if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return;
await spacedUpdate.updateNowIfNecessary();
})
// Save if needed upon window/browser closing.
useEffect(() => {
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
appContext.addBeforeUnloadListener(listener);
return () => appContext.removeBeforeUnloadListener(listener);
}, []);
return spacedUpdate;
}
/** /**
* Allows a React component to read and write a Trilium option, while also watching for external changes. * Allows a React component to read and write a Trilium option, while also watching for external changes.
* *
@@ -198,7 +260,7 @@ export function useNoteContext() {
const [ noteContext, setNoteContext ] = useState<NoteContext>(); const [ noteContext, setNoteContext ] = useState<NoteContext>();
const [ notePath, setNotePath ] = useState<string | null | undefined>(); const [ notePath, setNotePath ] = useState<string | null | undefined>();
const [ note, setNote ] = useState<FNote | null | undefined>(); const [ note, setNote ] = useState<FNote | null | undefined>();
const [ , setViewMode ] = useState<ViewMode>(); const [ , setViewScope ] = useState<ViewScope>();
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly); const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
const [ refreshCounter, setRefreshCounter ] = useState(0); const [ refreshCounter, setRefreshCounter ] = useState(0);
@@ -209,7 +271,7 @@ export function useNoteContext() {
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
setNoteContext(noteContext); setNoteContext(noteContext);
setNotePath(noteContext.notePath); setNotePath(noteContext.notePath);
setViewMode(noteContext.viewScope?.viewMode); setViewScope(noteContext.viewScope);
}); });
useTriliumEvent("frocaReloaded", () => { useTriliumEvent("frocaReloaded", () => {
setNote(noteContext?.note); setNote(noteContext?.note);
@@ -381,7 +443,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: Filte
] ]
} }
export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined { export function useNoteBlob(note: FNote | null | undefined, componentId?: string): FBlob | null | undefined {
const [ blob, setBlob ] = useState<FBlob | null>(); const [ blob, setBlob ] = useState<FBlob | null>();
function refresh() { function refresh() {
@@ -402,6 +464,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde
if (loadResults.hasRevisionForNote(note.noteId)) { if (loadResults.hasRevisionForNote(note.noteId)) {
refresh(); refresh();
} }
if (loadResults.isNoteContentReloaded(note.noteId, componentId)) {
refresh();
}
}); });
useDebugValue(note?.noteId); useDebugValue(note?.noteId);
@@ -675,26 +741,6 @@ export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | un
}, [ containerRef, callback ]); }, [ containerRef, callback ]);
} }
export function useTouchBar(
factory: (context: CommandListenerData<"buildTouchBar"> & { parentComponent: Component | null }) => void,
inputs: Inputs
) {
const parentComponent = useContext(ParentComponent);
useLegacyImperativeHandlers({
buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) {
return factory({
...context,
parentComponent
});
}
});
useEffect(() => {
parentComponent?.triggerCommand("refreshTouchBar");
}, inputs);
}
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) { export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
const resizeObserver = useRef<ResizeObserver>(null); const resizeObserver = useRef<ResizeObserver>(null);
useEffect(() => { useEffect(() => {
@@ -710,6 +756,20 @@ export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => v
}, [ callback, ref ]); }, [ callback, ref ]);
} }
export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", containerRef: RefObject<HTMLElement>, parentComponent: Component | undefined) {
useEffect(() => {
if (!parentComponent) return;
const $container = refToJQuerySelector(containerRef);
const bindingPromise = keyboard_actions.setupActionsForElement(scope, $container, parentComponent);
return async () => {
const bindings = await bindingPromise;
for (const binding of bindings) {
removeIndividualBinding(binding);
}
}
}, []);
}
/** /**
* Indicates that the current note is in read-only mode, while an editing mode is available, * Indicates that the current note is in read-only mode, while an editing mode is available,
* and provides a way to switch to editing mode. * and provides a way to switch to editing mode.

View File

@@ -18,58 +18,60 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
<div className="file-properties-widget"> <div className="file-properties-widget">
{note && ( {note && (
<table class="file-table"> <table class="file-table">
<tr> <tbody>
<th class="text-nowrap">{t("file_properties.note_id")}:</th> <tr>
<td class="file-note-id">{note.noteId}</td> <th class="text-nowrap">{t("file_properties.note_id")}:</th>
<th class="text-nowrap">{t("file_properties.original_file_name")}:</th> <td class="file-note-id">{note.noteId}</td>
<td class="file-filename">{originalFileName ?? "?"}</td> <th class="text-nowrap">{t("file_properties.original_file_name")}:</th>
</tr> <td class="file-filename">{originalFileName ?? "?"}</td>
<tr> </tr>
<th class="text-nowrap">{t("file_properties.file_type")}:</th> <tr>
<td class="file-filetype">{note.mime}</td> <th class="text-nowrap">{t("file_properties.file_type")}:</th>
<th class="text-nowrap">{t("file_properties.file_size")}:</th> <td class="file-filetype">{note.mime}</td>
<td class="file-filesize">{formatSize(blob?.contentLength ?? 0)}</td> <th class="text-nowrap">{t("file_properties.file_size")}:</th>
</tr> <td class="file-filesize">{formatSize(blob?.contentLength ?? 0)}</td>
</tr>
<tr> <tr>
<td colSpan={4}> <td colSpan={4}>
<div class="file-buttons"> <div class="file-buttons">
<Button <Button
icon="bx bx-download" icon="bx bx-download"
text={t("file_properties.download")} text={t("file_properties.download")}
primary primary
disabled={!canAccessProtectedNote} disabled={!canAccessProtectedNote}
onClick={() => downloadFileNote(note.noteId)} onClick={() => downloadFileNote(note.noteId)}
/> />
<Button <Button
icon="bx bx-link-external" icon="bx bx-link-external"
text={t("file_properties.open")} text={t("file_properties.open")}
disabled={note.isProtected} disabled={note.isProtected}
onClick={() => openNoteExternally(note.noteId, note.mime)} onClick={() => openNoteExternally(note.noteId, note.mime)}
/> />
<FormFileUploadButton <FormFileUploadButton
icon="bx bx-folder-open" icon="bx bx-folder-open"
text={t("file_properties.upload_new_revision")} text={t("file_properties.upload_new_revision")}
disabled={!canAccessProtectedNote} disabled={!canAccessProtectedNote}
onChange={(fileToUpload) => { onChange={(fileToUpload) => {
if (!fileToUpload) { if (!fileToUpload) {
return; return;
}
server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
if (result.uploaded) {
toast.showMessage(t("file_properties.upload_success"));
} else {
toast.showError(t("file_properties.upload_failed"));
} }
});
}} server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
/> if (result.uploaded) {
</div> toast.showMessage(t("file_properties.upload_success"));
</td> } else {
</tr> toast.showError(t("file_properties.upload_failed"));
}
});
}}
/>
</div>
</td>
</tr>
</tbody>
</table> </table>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { ParentComponent } from "../react/react_utils";
import { t } from "../../services/i18n" import { t } from "../../services/i18n"
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useIsNoteReadOnly } from "../react/hooks"; import { useIsNoteReadOnly } from "../react/hooks";
import { useTriliumOption } from "../react/hooks";
import ActionButton from "../react/ActionButton" import ActionButton from "../react/ActionButton"
import appContext, { CommandNames } from "../../components/app_context"; import appContext, { CommandNames } from "../../components/app_context";
import branches from "../../services/branches"; import branches from "../../services/branches";
@@ -51,8 +52,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation"); const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation");
const isElectron = getIsElectron(); const isElectron = getIsElectron();
const isMac = getIsMac(); const isMac = getIsMac();
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type); const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
const isSearchOrBook = ["search", "book"].includes(note.type); const isSearchOrBook = ["search", "book"].includes(note.type);
const [ syncServerHost ] = useTriliumOption("syncServerHost");
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext); const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
return ( return (
@@ -90,6 +92,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} /> <CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} /> <CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} /> <CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
{(syncServerHost && isElectron) &&
<CommandItem command="openNoteOnServer" icon="bx bx-world" disabled={!syncServerHost} text={t("note_actions.open_note_on_server")} />
}
<FormDropdownDivider /> <FormDropdownDivider />
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} /> <CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />

View File

@@ -36,57 +36,58 @@ export default function NoteInfoTab({ note }: TabContext) {
return ( return (
<div className="note-info-widget"> <div className="note-info-widget">
{note && ( {note && (
<table className="note-info-widget-table"> <>
<tbody> <div className="note-info-item">
<tr> <span>{t("note_info_widget.note_id")}:</span>
<th>{t("note_info_widget.note_id")}:</th> <span className="note-info-id">{note.noteId}</span>
<td class="note-info-id">{note.noteId}</td> </div>
<th>{t("note_info_widget.created")}:</th> <div className="note-info-item">
<td>{formatDateTime(metadata?.dateCreated)}</td> <span>{t("note_info_widget.created")}:</span>
<th>{t("note_info_widget.modified")}:</th> <span>{formatDateTime(metadata?.dateCreated)}</span>
<td>{formatDateTime(metadata?.dateModified)}</td> </div>
</tr> <div className="note-info-item">
<span>{t("note_info_widget.modified")}:</span>
<span>{formatDateTime(metadata?.dateModified)}</span>
</div>
<div className="note-info-item">
<span>{t("note_info_widget.type")}:</span>
<span>
<span className="note-info-type">{note.type}</span>{' '}
{note.mime && <span className="note-info-mime">({note.mime})</span>}
</span>
</div>
<div className="note-info-item">
<span title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</span>
<span className="note-info-size-col-span">
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
<Button
className="calculate-button"
icon="bx bx-calculator"
text={t("note_info_widget.calculate")}
onClick={() => {
setIsLoading(true);
setTimeout(async () => {
await Promise.allSettled([
server.get<NoteSizeResponse>(`stats/note-size/${note.noteId}`).then(setNoteSizeResponse),
server.get<SubtreeSizeResponse>(`stats/subtree-size/${note.noteId}`).then(setSubtreeSizeResponse)
]);
setIsLoading(false);
}, 0);
}}
/>
)}
<tr> <span className="note-sizes-wrapper">
<th>{t("note_info_widget.type")}:</th> <span className="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
<td> {" "}
<span class="note-info-type">{note.type}</span>{' '} {subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
{ note.mime && <span class="note-info-mime">({note.mime})</span> } <span className="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
</td> }
{isLoading && <LoadingSpinner />}
<th title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</th> </span>
<td colSpan={3}> </span>
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && ( </div>
<Button </>
className="calculate-button"
style={{ padding: "0px 10px 0px 10px" }}
icon="bx bx-calculator"
text={t("note_info_widget.calculate")}
onClick={() => {
setIsLoading(true);
setTimeout(async () => {
await Promise.allSettled([
server.get<NoteSizeResponse>(`stats/note-size/${note.noteId}`).then(setNoteSizeResponse),
server.get<SubtreeSizeResponse>(`stats/subtree-size/${note.noteId}`).then(setSubtreeSizeResponse)
]);
setIsLoading(false);
}, 0);
}}
/>
)}
<span className="note-sizes-wrapper">
<span class="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
{" "}
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
<span class="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
}
{isLoading && <LoadingSpinner />}
</span>
</td>
</tr>
</tbody>
</table>
)} )}
</div> </div>
) )

View File

@@ -1,24 +1,19 @@
import { TabContext } from "./ribbon-interface"; import { TabContext } from "./ribbon-interface";
import NoteMapWidget from "../note_map"; import { useElementSize, useWindowSize } from "../react/hooks";
import { useElementSize, useLegacyWidget, useWindowSize } from "../react/hooks";
import ActionButton from "../react/ActionButton"; import ActionButton from "../react/ActionButton";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import NoteMap from "../note_map/NoteMap";
const SMALL_SIZE_HEIGHT = "300px"; const SMALL_SIZE_HEIGHT = "300px";
export default function NoteMapTab({ noteContext }: TabContext) { export default function NoteMapTab({ note }: TabContext) {
const [ isExpanded, setExpanded ] = useState(false); const [ isExpanded, setExpanded ] = useState(false);
const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT); const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { windowHeight } = useWindowSize(); const { windowHeight } = useWindowSize();
const containerSize = useElementSize(containerRef); const containerSize = useElementSize(containerRef);
const [ noteMapContainer, noteMapWidget ] = useLegacyWidget(() => new NoteMapWidget("ribbon"), {
noteContext,
containerClassName: "note-map-container"
});
useEffect(() => { useEffect(() => {
if (isExpanded && containerRef.current && containerSize) { if (isExpanded && containerRef.current && containerSize) {
const height = windowHeight - containerSize.top; const height = windowHeight - containerSize.top;
@@ -27,11 +22,10 @@ export default function NoteMapTab({ noteContext }: TabContext) {
setHeight(SMALL_SIZE_HEIGHT); setHeight(SMALL_SIZE_HEIGHT);
} }
}, [ isExpanded, containerRef, windowHeight, containerSize?.top ]); }, [ isExpanded, containerRef, windowHeight, containerSize?.top ]);
useEffect(() => noteMapWidget.setDimensions(), [ containerSize?.width, height ]);
return ( return (
<div className="note-map-ribbon-widget" style={{ height }} ref={containerRef}> <div className="note-map-ribbon-widget" style={{ height }} ref={containerRef}>
{noteMapContainer} {note && <NoteMap note={note} widgetMode="ribbon" parentRef={containerRef} />}
{!isExpanded ? ( {!isExpanded ? (
<ActionButton <ActionButton

View File

@@ -160,17 +160,20 @@
/* #region Note info */ /* #region Note info */
.note-info-widget { .note-info-widget {
padding: 12px; padding: 12px;
display: flex;
flex-wrap: wrap;
align-items: baseline;
} }
.note-info-widget-table { .note-info-item {
max-width: 100%; display: flex;
display: block; align-items: baseline;
overflow-x: auto; padding-inline-end: 15px;
white-space: nowrap; padding-block: 5px;
} }
.note-info-widget-table td, .note-info-widget-table th { .note-info-item > span:first-child {
padding: 5px; padding-inline-end: 5px;
} }
.note-info-mime { .note-info-mime {
@@ -186,6 +189,10 @@
font-size: 0.8em; font-size: 0.8em;
vertical-align: middle !important; vertical-align: middle !important;
} }
.note-info-widget .calculate-button {
padding: 0 10px;
}
/* #endregion */ /* #endregion */
/* #region Similar Notes */ /* #region Similar Notes */

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from "preact/hooks";
import { useEditorSpacedUpdate, useLegacyWidget } from "../react/hooks";
import { type TypeWidgetProps } from "./type_widget";
import LlmChatPanel from "../llm_chat";
export default function AiChat({ note, noteContext }: TypeWidgetProps) {
const dataRef = useRef<object>();
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: async () => ({
content: JSON.stringify(dataRef.current)
}),
onContentChange: (newContent) => {
try {
dataRef.current = JSON.parse(newContent);
llmChatPanel.refresh();
} catch (e) {
dataRef.current = {};
}
}
});
const [ ChatWidget, llmChatPanel ] = useLegacyWidget(() => {
const llmChatPanel = new LlmChatPanel();
llmChatPanel.setDataCallbacks(
async (data) => {
dataRef.current = data;
spacedUpdate.scheduleUpdate();
},
async () => dataRef.current
);
return llmChatPanel;
}, {
noteContext,
containerStyle: {
height: "100%"
}
});
useEffect(() => {
llmChatPanel.setNoteId(note.noteId);
llmChatPanel.setCurrentNoteId(note.noteId);
}, [ note ]);
return ChatWidget;
}

View File

@@ -0,0 +1,137 @@
/* #region Attachment list */
.attachment-list {
padding-inline-start: 15px;
padding-inline-end: 15px;
}
.attachment-list .links-wrapper {
font-size: larger;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: baseline;
}
/* #endregion */
/* #region Attachment info */
.attachment-detail-widget {
height: 100%;
}
.attachment-detail-wrapper {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.attachment-title-line {
display: flex;
align-items: baseline;
gap: 1em;
}
.attachment-details {
margin-inline-start: 10px;
}
.attachment-content-wrapper {
flex-grow: 1;
}
.attachment-content-wrapper .rendered-content {
height: 100%;
}
.attachment-content-wrapper pre {
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper {
max-height: 300px;
}
.attachment-detail-wrapper.full-detail {
height: 100%;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper {
height: 100%;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper pre {
max-height: 400px;
}
.attachment-content-wrapper img {
margin: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
max-height: 300px;
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper img {
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%);
}
.attachment-detail-wrapper .attachment-deletion-warning {
margin-top: 15px;
}
/* #endregion */
/* #region Attachment detail */
.attachment-detail {
padding-left: 15px;
padding-right: 15px;
height: 100%;
display: flex;
flex-direction: column;
}
.attachment-detail .links-wrapper {
font-size: larger;
padding: 0 0 16px 0;
}
.attachment-detail .attachment-wrapper {
flex-grow: 1;
}
/* #endregion */
/* #region Attachment actions */
.attachment-actions {
width: 35px;
height: 35px;
}
.attachment-actions .select-button {
position: relative;
top: 3px;
}
.attachment-actions .dropdown-menu {
width: 20em;
}
.attachment-actions .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-inline-end: 5px;
}
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
color: var(--muted-text-color) !important;
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
/* #endregion */

View File

@@ -0,0 +1,310 @@
import { t } from "i18next";
import { TypeWidgetProps } from "./type_widget";
import "./Attachment.css";
import NoteLink from "../react/NoteLink";
import Button from "../react/Button";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
import HelpButton from "../react/HelpButton";
import FAttachment from "../../entities/fattachment";
import Alert from "../react/Alert";
import utils from "../../services/utils";
import content_renderer from "../../services/content_renderer";
import { useTriliumEvent } from "../react/hooks";
import froca from "../../services/froca";
import Dropdown from "../react/Dropdown";
import Icon from "../react/Icon";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import open from "../../services/open";
import toast from "../../services/toast";
import link from "../../services/link";
import image from "../../services/image";
import FormFileUpload from "../react/FormFileUpload";
import server from "../../services/server";
import dialog from "../../services/dialog";
import ws from "../../services/ws";
import appContext from "../../components/app_context";
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
import options from "../../services/options";
/**
* Displays the full list of attachments of a note and allows the user to interact with them.
*/
export function AttachmentList({ note }: TypeWidgetProps) {
const [ attachments, setAttachments ] = useState<FAttachment[]>([]);
function refresh() {
note.getAttachments().then(attachments => setAttachments(Array.from(attachments)));
}
useEffect(refresh, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) {
refresh();
}
});
return (
<>
<AttachmentListHeader noteId={note.noteId} />
<div className="attachment-list-wrapper">
{attachments.length ? (
attachments.map(attachment => <AttachmentInfo key={attachment.attachmentId} attachment={attachment} />)
) : (
<Alert type="info">
{t("attachment_list.no_attachments")}
</Alert>
)}
</div>
</>
)
}
function AttachmentListHeader({ noteId }: { noteId: string }) {
const parentComponent = useContext(ParentComponent);
return (
<div className="links-wrapper">
<div>
{t("attachment_list.owning_note")}{" "}<NoteLink notePath={noteId} />
</div>
<div className="attachment-actions-toolbar">
<Button
size="small"
icon="bx bx-folder-open"
text={t("attachment_list.upload_attachments")}
onClick={() => parentComponent?.triggerCommand("showUploadAttachmentsDialog", { noteId })}
/>
&nbsp;
<HelpButton
helpPage="0vhv7lsOLy82"
title={t("attachment_list.open_help_page")}
/>
</div>
</div>
)
}
/**
* Displays information about a single attachment.
*/
export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
const [ attachment, setAttachment ] = useState<FAttachment | null | undefined>(undefined);
useEffect(() => {
if (!viewScope?.attachmentId) return;
froca.getAttachment(viewScope.attachmentId).then(setAttachment);
}, [ viewScope ]);
return (
<>
<div className="links-wrapper use-tn-links">
{t("attachment_detail.owning_note")}{" "}
<NoteLink notePath={note.noteId} />
{t("attachment_detail.you_can_also_open")}{" "}
<NoteLink
notePath={note.noteId}
viewScope={{ viewMode: "attachments" }}
title={t("attachment_detail.list_of_all_attachments")}
/>
<HelpButton
helpPage="0vhv7lsOLy82"
title={t("attachment_list.open_help_page")}
/>
</div>
<div className="attachment-wrapper">
{attachment !== null ? (
attachment && <AttachmentInfo attachment={attachment} isFullDetail />
) : (
<strong>{t("attachment_detail.attachment_deleted")}</strong>
)}
</div>
</>
)
}
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
const contentWrapper = useRef<HTMLDivElement>(null);
function refresh() {
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
.then(({ $renderedContent }) => {
contentWrapper.current?.replaceChildren(...$renderedContent);
});
}
useEffect(refresh, [ attachment ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttachmentRows().find(attachment => attachment.attachmentId)) {
refresh();
}
});
async function copyAttachmentLinkToClipboard() {
if (attachment.role === "image") {
const $contentWrapper = refToJQuerySelector(contentWrapper);
image.copyImageReferenceToClipboard($contentWrapper);
} else if (attachment.role === "file") {
const $link = await link.createLink(attachment.ownerId, {
referenceLink: true,
viewScope: {
viewMode: "attachments",
attachmentId: attachment.attachmentId
}
});
utils.copyHtmlToClipboard($link[0].outerHTML);
toast.showMessage(t("attachment_detail_2.link_copied"));
} else {
throw new Error(t("attachment_detail_2.unrecognized_role", { role: attachment.role }));
}
}
return (
<div className="attachment-detail-widget">
<div className={`attachment-detail-wrapper ${isFullDetail ? "full-detail" : "list-view"} ${attachment.utcDateScheduledForErasureSince ? "scheduled-for-deletion" : ""}`}>
<div className="attachment-title-line">
<AttachmentActions attachment={attachment} copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard} />
<h4 className="attachment-title">
{!isFullDetail ? (
<NoteLink
notePath={attachment.ownerId}
title={attachment.title}
viewScope={{
viewMode: "attachments",
attachmentId: attachment.attachmentId
}}
/>
) : (attachment.title)}
</h4>
<div className="attachment-details">
{t("attachment_detail_2.role_and_size", { role: attachment.role, size: utils.formatSize(attachment.contentLength) })}
</div>
<div style="flex: 1 1;"></div>
</div>
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
<div ref={contentWrapper} className="attachment-content-wrapper" />
</div>
</div>
)
}
function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledForErasureSince: string }) {
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
// use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
const willBeDeletedInMs = deletionTimestamp - Date.now();
return (
<Alert className="attachment-deletion-warning" type="info">
{ willBeDeletedInMs >= 60000
? t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) })
: t("attachment_detail_2.will_be_deleted_soon")}
{t("attachment_detail_2.deletion_reason")}
</Alert>
)
}
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) {
const isElectron = utils.isElectron();
const fileUploadRef = useRef<HTMLInputElement>(null);
return (
<div className="attachment-actions-container">
<Dropdown
className="attachment-actions"
text={<Icon icon="bx bx-dots-vertical-rounded" />}
buttonClassName="icon-action-always-border"
iconAction
>
<FormListItem
icon="bx bx-file-find"
title={t("attachments_actions.open_externally_title")}
onClick={() => open.openAttachmentExternally(attachment.attachmentId, attachment.mime)}
>{t("attachments_actions.open_externally")}</FormListItem>
<FormListItem
icon="bx bx-customize"
title={t("attachments_actions.open_custom_title")}
onClick={() => open.openAttachmentCustom(attachment.attachmentId, attachment.mime)}
disabled={!isElectron}
disabledTooltip={!isElectron ? t("attachments_actions.open_custom_client_only") : t("attachments_actions.open_externally_detail_page")}
>{t("attachments_actions.open_custom")}</FormListItem>
<FormListItem
icon="bx bx-download"
onClick={() => open.downloadAttachment(attachment.attachmentId)}
>{t("attachments_actions.download")}</FormListItem>
<FormListItem
icon="bx bx-link"
onClick={copyAttachmentLinkToClipboard}
>{t("attachments_actions.copy_link_to_clipboard")}</FormListItem>
<FormDropdownDivider />
<FormListItem
icon="bx bx-upload"
onClick={() => fileUploadRef.current?.click()}
>{t("attachments_actions.upload_new_revision")}</FormListItem>
<FormListItem
icon="bx bx-rename"
onClick={async () => {
const attachmentTitle = await dialog.prompt({
title: t("attachments_actions.rename_attachment"),
message: t("attachments_actions.enter_new_name"),
defaultValue: attachment.title
});
if (!attachmentTitle?.trim()) return;
await server.put(`attachments/${attachment.attachmentId}/rename`, { title: attachmentTitle });
}}
>{t("attachments_actions.rename_attachment")}</FormListItem>
<FormListItem
icon="bx bx-trash destructive-action-icon"
onClick={async () => {
if (!(await dialog.confirm(t("attachments_actions.delete_confirm", { title: attachment.title })))) {
return;
}
await server.remove(`attachments/${attachment.attachmentId}`);
toast.showMessage(t("attachments_actions.delete_success", { title: attachment.title }));
}}
>{t("attachments_actions.delete_attachment")}</FormListItem>
<FormDropdownDivider />
<FormListItem
icon="bx bx-note"
onClick={async () => {
if (!(await dialog.confirm(t("attachments_actions.convert_confirm", { title: attachment.title })))) {
return;
}
const { note: newNote } = await server.post<ConvertAttachmentToNoteResponse>(`attachments/${attachment.attachmentId}/convert-to-note`);
toast.showMessage(t("attachments_actions.convert_success", { title: attachment.title }));
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
}}
>{t("attachments_actions.convert_attachment_into_note")}</FormListItem>
<FormFileUpload
inputRef={fileUploadRef}
hidden
onChange={async files => {
const fileToUpload = files?.item(0);
if (fileToUpload) {
const result = await server.upload(`attachments/${attachment.attachmentId}/file`, fileToUpload);
if (result.uploaded) {
toast.showMessage(t("attachments_actions.upload_success"));
} else {
toast.showError(t("attachments_actions.upload_failed"));
}
}
}}
/>
</Dropdown>
</div>
)
}

View File

@@ -0,0 +1,4 @@
.note-detail-book-empty-help {
margin: 50px;
padding: 20px;
}

View File

@@ -0,0 +1,35 @@
import { t } from "../../services/i18n";
import Alert from "../react/Alert";
import { useNoteLabel, useTriliumEvent } from "../react/hooks";
import RawHtml from "../react/RawHtml";
import { TypeWidgetProps } from "./type_widget";
import "./Book.css";
import { useEffect, useState } from "preact/hooks";
const VIEW_TYPES = [ "list", "grid" ];
export default function Book({ note }: TypeWidgetProps) {
const [ viewType ] = useNoteLabel(note, "viewType");
const [ shouldDisplayNoChildrenWarning, setShouldDisplayNoChildrenWarning ] = useState(false);
function refresh() {
setShouldDisplayNoChildrenWarning(!note.hasChildren() && VIEW_TYPES.includes(viewType ?? ""));
}
useEffect(refresh, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getBranchRows().some(branchRow => branchRow.parentNoteId === note.noteId)) {
refresh();
}
});
return (
<>
{shouldDisplayNoChildrenWarning && (
<Alert type="warning" className="note-detail-book-empty-help">
<RawHtml html={t("book.no_children_help")} />
</Alert>
)}
</>
)
}

View File

@@ -0,0 +1,16 @@
.type-contentWidget .note-detail {
height: 100%;
}
.note-detail-content-widget {
height: 100%;
}
.note-detail-content-widget-content {
padding: 15px;
height: 100%;
}
.note-detail.full-height .note-detail-content-widget-content {
padding: 0;
}

View File

@@ -0,0 +1,58 @@
import { TypeWidgetProps } from "./type_widget";
import { JSX } from "preact/jsx-runtime";
import AppearanceSettings from "./options/appearance";
import ShortcutSettings from "./options/shortcuts";
import TextNoteSettings from "./options/text_notes";
import CodeNoteSettings from "./options/code_notes";
import ImageSettings from "./options/images";
import SpellcheckSettings from "./options/spellcheck";
import PasswordSettings from "./options/password";
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication";
import EtapiSettings from "./options/etapi";
import BackupSettings from "./options/backup";
import SyncOptions from "./options/sync";
import AiSettings from "./options/ai_settings";
import OtherSettings from "./options/other";
import InternationalizationOptions from "./options/i18n";
import AdvancedSettings from "./options/advanced";
import "./ContentWidget.css";
import { t } from "../../services/i18n";
import BackendLog from "./code/BackendLog";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
_optionsAppearance: AppearanceSettings,
_optionsShortcuts: ShortcutSettings,
_optionsTextNotes: TextNoteSettings,
_optionsCodeNotes: CodeNoteSettings,
_optionsImages: ImageSettings,
_optionsSpellcheck: SpellcheckSettings,
_optionsPassword: PasswordSettings,
_optionsMFA: MultiFactorAuthenticationSettings,
_optionsEtapi: EtapiSettings,
_optionsBackup: BackupSettings,
_optionsSync: SyncOptions,
_optionsAi: AiSettings,
_optionsOther: OtherSettings,
_optionsLocalization: InternationalizationOptions,
_optionsAdvanced: AdvancedSettings,
_backendLog: BackendLog
}
/**
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
*
* @param param0
* @returns
*/
export default function ContentWidget({ note, ...restProps }: TypeWidgetProps) {
const Content = CONTENT_WIDGETS[note.noteId];
return (
<div className={`note-detail-content-widget-content ${note.noteId.startsWith("_options") ? "options" : ""}`}>
{Content
? <Content note={note} {...restProps} />
: (t("content_widget.unknown_widget", { id: note.noteId }))}
</div>
)
}

View File

@@ -0,0 +1,50 @@
.note-detail-doc-content {
padding: 15px;
}
.note-detail-doc-content pre {
border: 0;
box-shadow: var(--code-block-box-shadow);
padding: 15px;
border-radius: 5px;
}
.note-detail-doc-content code {
font-variant: none;
}
.note-detail-doc-content pre:not(.hljs) {
background-color: var(--accented-background-color);
border: 1px solid var(--main-border-color);
}
.note-detail-doc-content.contextual-help {
padding-bottom: 0;
}
.note-detail-doc-content.contextual-help h2,
.note-detail-doc-content.contextual-help h3,
.note-detail-doc-content.contextual-help h4,
.note-detail-doc-content.contextual-help h5,
.note-detail-doc-content.contextual-help h6 {
font-size: 1.25rem;
background-color: var(--main-background-color);
position: sticky;
top: 0;
z-index: 50;
margin: 0;
padding-bottom: 0.25em;
}
img {
max-width: 100%;
height: auto;
}
td img {
max-width: 40vw;
}
figure.table {
overflow: auto !important;
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { RawHtmlBlock } from "../react/RawHtml";
import renderDoc from "../../services/doc_renderer";
import "./Doc.css";
import { TypeWidgetProps } from "./type_widget";
import { useTriliumEvent } from "../react/hooks";
import { refToJQuerySelector } from "../react/react_utils";
export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) {
const [ html, setHtml ] = useState<string>();
const initialized = useRef<Promise<void> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!note) return;
initialized.current = renderDoc(note).then($content => {
setHtml($content.html());
});
}, [ note ]);
useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => {
if (eventNtxId !== ntxId) return;
await initialized.current;
resolve(refToJQuerySelector(containerRef));
});
return (
<RawHtmlBlock
containerRef={containerRef}
className={`note-detail-doc-content ck-content ${viewScope?.viewMode === "contextual-help" ? "contextual-help" : ""}`}
html={html}
/>
);
}

View File

@@ -0,0 +1,38 @@
.workspace-notes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
}
.workspace-notes .workspace-note {
width: 130px;
text-align: center;
margin: 10px;
border: 1px transparent solid;
}
.workspace-notes .workspace-note:hover {
cursor: pointer;
border: 1px solid var(--main-border-color);
}
.note-detail-empty-results .aa-dropdown-menu {
max-height: 50vh;
overflow: scroll;
border: var(--bs-border-width) solid var(--bs-border-color);
border-top: 0;
}
.empty-tab-search .note-autocomplete-input {
border-bottom-left-radius: 0;
}
.empty-tab-search .input-clearer-button {
border-bottom-right-radius: 0;
}
.workspace-icon {
text-align: center;
font-size: 500%;
}

View File

@@ -0,0 +1,85 @@
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import FormGroup from "../react/FormGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import "./Empty.css";
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
import note_autocomplete from "../../services/note_autocomplete";
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote";
import search from "../../services/search";
import { TypeWidgetProps } from "./type_widget";
export default function Empty({ }: TypeWidgetProps) {
return (
<>
<WorkspaceSwitcher />
<NoteSearch />
</>
)
}
function NoteSearch() {
const resultsContainerRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLInputElement>(null);
// Show recent notes.
useEffect(() => {
const $autoComplete = refToJQuerySelector(autocompleteRef);
note_autocomplete.showRecentNotes($autoComplete);
}, []);
return (
<>
<FormGroup name="empty-tab-search" label={t("empty.open_note_instruction")} className="empty-tab-search">
<NoteAutocomplete
placeholder={t("empty.search_placeholder")}
container={resultsContainerRef}
inputRef={autocompleteRef}
opts={{
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true,
allowJumpToSearchNotes: true,
}}
onChange={suggestion => {
if (!suggestion?.notePath) {
return false;
}
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext) {
activeContext.setNote(suggestion.notePath);
}
}}
/>
</FormGroup>
<div ref={resultsContainerRef} className="note-detail-empty-results" />
</>
);
}
function WorkspaceSwitcher() {
const [ workspaceNotes, setWorkspaceNotes ] = useState<FNote[]>();
const parentComponent = useContext(ParentComponent);
function refresh() {
search.searchForNotes("#workspace #!template").then(setWorkspaceNotes);
}
useEffect(refresh, []);
return (
<div class="workspace-notes">
{workspaceNotes?.map(workspaceNote => (
<div
className="workspace-note"
title={t("empty.enter_workspace", { title: workspaceNote.title })}
onClick={() => parentComponent?.triggerCommand("hoistNote", { noteId: workspaceNote.noteId })}
>
<div className={`${workspaceNote.getIcon()} workspace-icon`} />
<div>{workspaceNote.title}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
.type-file .note-detail {
height: 100%;
}
.note-detail-file {
padding: 10px;
height: 100%;
}
.note-split.full-content-width .note-detail-file {
padding: 0;
}
.note-detail.full-height .note-detail-file[data-preview-type="pdf"],
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
overflow: hidden;
}
.file-preview-content {
background-color: var(--accented-background-color);
padding: 15px;
height: 100%;
overflow: auto;
margin: 10px;
}
.note-detail-file > .pdf-preview,
.note-detail-file > .video-preview {
width: 100%;
height: 100%;
flex-grow: 100;
}
.note-detail-file > .audio-preview {
position: absolute;
top: 50%;
left: 15px;
right: 15px;
width: calc(100% - 30px);
transform: translateY(-50%);
}

View File

@@ -0,0 +1,78 @@
import { useNoteBlob } from "../react/hooks";
import "./File.css";
import { TypeWidgetProps } from "./type_widget";
import FNote from "../../entities/fnote";
import { getUrlForDownload } from "../../services/open";
import Alert from "../react/Alert";
import { t } from "../../services/i18n";
const TEXT_MAX_NUM_CHARS = 5000;
export default function File({ note }: TypeWidgetProps) {
const blob = useNoteBlob(note);
if (blob?.content) {
return <TextPreview content={blob.content} />
} else if (note.mime === "application/pdf") {
return <PdfPreview note={note} />
} else if (note.mime.startsWith("video/")) {
return <VideoPreview note={note} />
} else if (note.mime.startsWith("audio/")) {
return <AudioPreview note={note} />
} else {
return <NoPreview />
}
}
function TextPreview({ content }: { content: string }) {
const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS);
const isTooLarge = trimmedContent.length !== content.length;
return (
<>
{isTooLarge && (
<Alert type="info">
{t("file.too_big", { maxNumChars: TEXT_MAX_NUM_CHARS })}
</Alert>
)}
<pre class="file-preview-content">{trimmedContent}</pre>
</>
)
}
function PdfPreview({ note }: { note: FNote }) {
return (
<iframe
class="pdf-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open`)} />
);
}
function VideoPreview({ note }: { note: FNote }) {
return (
<video
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
controls
/>
)
}
function AudioPreview({ note }: { note: FNote }) {
return (
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
controls
/>
)
}
function NoPreview() {
return (
<Alert className="file-preview-not-available" type="info">
{t("file.file_preview_not_available")}
</Alert>
);
}

View File

@@ -0,0 +1,24 @@
.type-image .note-detail {
height: 100%;
}
.note-detail-image {
height: 100%;
}
.note-detail-image-wrapper {
position: relative;
display: flex;
align-items: center;
overflow: hidden;
justify-content: center;
height: 100%;
}
.note-detail-image-view {
display: block;
width: auto;
height: auto;
align-self: center;
flex-shrink: 0;
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { createImageSrcUrl } from "../../services/utils";
import { useTriliumEvent, useUniqueName } from "../react/hooks";
import "./Image.css";
import { TypeWidgetProps } from "./type_widget";
import WheelZoom from 'vanilla-js-wheel-zoom';
import image_context_menu from "../../menus/image_context_menu";
import { refToJQuerySelector } from "../react/react_utils";
import { copyImageReferenceToClipboard } from "../../services/image";
export default function Image({ note, ntxId }: TypeWidgetProps) {
const uniqueId = useUniqueName("image");
const containerRef = useRef<HTMLDivElement>(null);
const [ refreshCounter, setRefreshCounter ] = useState(0);
// Set up pan & zoom
useEffect(() => {
const zoomInstance = WheelZoom.create(`#${uniqueId}`, {
maxScale: 50,
speed: 1.3,
zoomOnClick: false
});
return () => zoomInstance.destroy();
}, [ note ]);
// Set up context menu
useEffect(() => image_context_menu.setupContextMenu(refToJQuerySelector(containerRef)), []);
// Copy reference events
useTriliumEvent("copyImageReferenceToClipboard", ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
copyImageReferenceToClipboard(refToJQuerySelector(containerRef));
});
// React to new revisions.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.isNoteReloaded(note.noteId)) {
setRefreshCounter(refreshCounter + 1);
}
});
return (
<div ref={containerRef} className="note-detail-image-wrapper">
<img
id={uniqueId}
className="note-detail-image-view"
src={createImageSrcUrl(note)}
/>
</div>
)
}

View File

@@ -1,21 +1,13 @@
import type { EditorConfig } from "@triliumnext/codemirror"; import { useCallback } from "preact/hooks";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js"; import SvgSplitEditor from "./helpers/SvgSplitEditor";
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js"; import { TypeWidgetProps } from "./type_widget";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
let idCounter = 1; let idCounter = 1;
let registeredErrorReporter = false; let registeredErrorReporter = false;
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget { export default function Mermaid(props: TypeWidgetProps) {
const renderSvg = useCallback(async (content: string) => {
static getType() {
return "mermaid";
}
get attachmentName(): string {
return "mermaid-export";
}
async renderSvg(content: string) {
const mermaid = (await import("mermaid")).default; const mermaid = (await import("mermaid")).default;
await loadElkIfNeeded(mermaid, content); await loadElkIfNeeded(mermaid, content);
if (!registeredErrorReporter) { if (!registeredErrorReporter) {
@@ -31,6 +23,13 @@ export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
idCounter++; idCounter++;
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content); const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
return postprocessMermaidSvg(svg); return postprocessMermaidSvg(svg);
} }, []);
return (
<SvgSplitEditor
attachmentName="mermaid-export"
renderSvg={renderSvg}
{...props}
/>
);
} }

View File

@@ -0,0 +1,124 @@
.note-detail-mind-map {
height: 100%;
overflow: hidden !important;
}
.note-detail-mind-map .mind-map-container {
height: 100%;
}
.map-container .node-menu {
position: absolute;
top: 60px;
inset-inline-end: 20px;
bottom: 80px;
overflow: auto;
background: var(--panel-bgcolor);
color: var(--main-color);
border-radius: 5px;
box-shadow: 0 1px 2px #0003;
width: 240px;
box-sizing: border-box;
padding: 0 15px 15px;
transition: .3s all
}
.map-container .node-menu.close {
height: 29px;
width: 46px;
overflow: hidden
}
.map-container .node-menu .button-container {
padding: 3px 0;
direction: rtl
}
.map-container .node-menu #nm-tag {
margin-top: 20px
}
.map-container .node-menu .nm-fontsize-container {
display: flex;
justify-content: space-around;
margin-bottom: 20px
}
.map-container .node-menu .nm-fontsize-container div {
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px #0003;
background-color: #fff;
color: tomato;
border-radius: 100%
}
.map-container .node-menu .nm-fontcolor-container {
margin-bottom: 10px
}
.map-container .node-menu input,
.map-container .node-menu textarea {
background: var(--input-background-color);
border: 1px solid var(--panel-border-color);
border-radius: var(--bs-border-radius);
color: var(--main-color);
padding: 5px;
margin: 10px 0;
width: 100%;
box-sizing: border-box;
}
.map-container .node-menu textarea {
resize: none
}
.map-container .node-menu .split6 {
display: inline-block;
width: 16.66%;
margin-bottom: 5px
}
.map-container .node-menu .palette {
border-radius: 100%;
width: 21px;
height: 21px;
border: 1px solid #edf1f2;
margin: auto
}
.map-container .node-menu .nmenu-selected,
.map-container .node-menu .palette:hover {
box-shadow: tomato 0 0 0 2px;
background-color: #c7e9fa
}
.map-container .node-menu .size-selected {
background-color: tomato !important;
border-color: tomato;
fill: #fff;
color: #fff
}
.map-container .node-menu .size-selected svg {
color: #fff
}
.map-container .node-menu .bof {
text-align: center
}
.map-container .node-menu .bof span {
display: inline-block;
font-size: 14px;
border-radius: 4px;
padding: 2px 5px
}
.map-container .node-menu .bof .selected {
background-color: tomato;
color: #fff
}

View File

@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef } from "preact/hooks";
import { TypeWidgetProps } from "./type_widget";
import { MindElixirData, MindElixirInstance, Operation, default as VanillaMindElixir } from "mind-elixir";
import { HTMLAttributes, RefObject } from "preact";
// allow node-menu plugin css to be bundled by webpack
import nodeMenu from "@mind-elixir/node-menu";
import "mind-elixir/style";
import "@mind-elixir/node-menu/dist/style.css";
import "./MindMap.css";
import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents } from "../react/hooks";
import { refToJQuerySelector } from "../react/react_utils";
import utils from "../../services/utils";
const NEW_TOPIC_NAME = "";
interface MindElixirProps {
apiRef?: RefObject<MindElixirInstance>;
containerProps?: Omit<HTMLAttributes<HTMLDivElement>, "ref">;
containerRef?: RefObject<HTMLDivElement>;
editable: boolean;
onChange?: () => void;
}
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: async () => {
if (!apiRef.current) return;
return {
content: apiRef.current.getDataString(),
attachments: [
{
role: "image",
title: "mindmap-export.svg",
mime: "image/svg+xml",
content: await apiRef.current.exportSvg().text(),
position: 0
}
]
}
},
onContentChange: (content) => {
let newContent: MindElixirData;
if (content) {
try {
newContent = JSON.parse(content) as MindElixirData;
} catch (e) {
console.warn(e);
console.debug("Wrong JSON content: ", content);
}
} else {
newContent = VanillaMindElixir.new(NEW_TOPIC_NAME)
}
apiRef.current?.init(newContent!);
}
});
// Allow search.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
resolve(refToJQuerySelector(containerRef).find(".map-canvas"));
});
// Export as PNG or SVG.
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
if (eventNtxId !== ntxId || !apiRef.current) return;
const title = note.title;
const svg = await apiRef.current.exportSvg().text();
if (eventName === "exportSvg") {
utils.downloadSvg(title, svg);
} else {
utils.downloadSvgAsPng(title, svg);
}
});
const onKeyDown = useCallback((e: KeyboardEvent) => {
/*
* Some global shortcuts interfere with the default shortcuts of the mind map,
* as defined here: https://mind-elixir.com/docs/guides/shortcuts
*/
if (e.key === "F1") {
e.stopPropagation();
}
// Zoom controls
const isCtrl = e.ctrlKey && !e.altKey && !e.metaKey;
if (isCtrl && (e.key == "-" || e.key == "=" || e.key == "0")) {
e.stopPropagation();
}
}, []);
return (
<MindElixir
containerRef={containerRef}
apiRef={apiRef}
onChange={() => spacedUpdate.scheduleUpdate()}
editable={!isReadOnly}
containerProps={{
className: "mind-map-container",
onKeyDown
}}
/>
)
}
function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) {
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
const apiRef = useRef<MindElixirInstance>(null);
function reinitialize() {
if (!containerRef.current) return;
const mind = new VanillaMindElixir({
el: containerRef.current,
editable
});
if (editable) {
mind.install(nodeMenu);
}
apiRef.current = mind;
if (externalApiRef) {
externalApiRef.current = mind;
}
}
useEffect(() => {
reinitialize();
return () => {
apiRef.current?.destroy();
apiRef.current = null;
};
}, []);
useEffect(() => {
const data = apiRef.current?.getData();
reinitialize();
if (data) {
apiRef.current?.init(data);
}
}, [ editable ]);
// On change listener.
useEffect(() => {
const bus = apiRef.current?.bus;
if (!onChange || !bus) return;
const operationListener = (operation: Operation) => {
if (operation.name !== "beginEdit") {
onChange();
}
}
bus.addListener("operation", operationListener);
bus.addListener("changeDirection", onChange);
return () => {
bus.removeListener("operation", operationListener);
bus.removeListener("changeDirection", onChange);
};
}, [ onChange ]);
return (
<div ref={containerRef} {...containerProps} />
)
}

View File

@@ -0,0 +1,13 @@
import { TypeWidgetProps } from "./type_widget";
import NoteMapEl from "../note_map/NoteMap";
import { useRef } from "preact/hooks";
export default function NoteMap({ note }: TypeWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef}>
<NoteMapEl parentRef={containerRef} note={note} widgetMode="type" />
</div>
);
}

View File

@@ -0,0 +1,9 @@
.protected-session-password-component {
width: 300px;
margin: 30px auto auto;
}
.protected-session-password-component input,
.protected-session-password-component button {
margin-top: 12px;
}

View File

@@ -0,0 +1,40 @@
import { useCallback, useRef } from "preact/hooks";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup";
import FormTextBox from "../react/FormTextBox";
import "./ProtectedSession.css";
import protected_session from "../../services/protected_session";
import type { TargetedSubmitEvent } from "preact";
export default function ProtectedSession() {
const passwordRef = useRef<HTMLInputElement>(null);
const submitCallback = useCallback((e: TargetedSubmitEvent<HTMLFormElement>) => {
if (!passwordRef.current) return;
e.preventDefault();
const password = String(passwordRef.current.value);
passwordRef.current.value = "";
protected_session.setupProtectedSession(password);
}, [ passwordRef ]);
return (
<form class="protected-session-password-form" onSubmit={submitCallback}>
<FormGroup name="protected-session-password-in-detail" label={t("protected_session.enter_password_instruction")}>
<FormTextBox
type="password"
className="protected-session-password"
autocomplete="current-password"
inputRef={passwordRef}
/>
</FormGroup>
<Button
text={t("protected_session.start_session_button")}
primary
keyboardShortcut="Enter"
/>
</form>
)
}

View File

@@ -0,0 +1,8 @@
.note-detail-render {
position: relative;
}
.note-detail-render .note-detail-render-help {
margin: 50px;
padding: 20px;
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { TypeWidgetProps } from "./type_widget";
import render from "../../services/render";
import { refToJQuerySelector } from "../react/react_utils";
import Alert from "../react/Alert";
import "./Render.css";
import { t } from "../../services/i18n";
import RawHtml from "../react/RawHtml";
import { useTriliumEvent } from "../react/hooks";
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
const contentRef = useRef<HTMLDivElement>(null);
const [ renderNotesFound, setRenderNotesFound ] = useState(false);
function refresh() {
if (!contentRef) return;
render.render(note, refToJQuerySelector(contentRef)).then(setRenderNotesFound);
}
useEffect(refresh, [ note ]);
// Keyboard shortcut.
useTriliumEvent("renderActiveNote", () => {
if (!noteContext?.isActive()) return;
refresh();
});
// Refresh on floating buttons.
useTriliumEvent("refreshData", ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
refresh();
});
// Integration with search.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
resolve(refToJQuerySelector(contentRef));
});
return (
<>
{!renderNotesFound && (
<Alert className="note-detail-render-help" type="warning">
<p><strong>{t("render.note_detail_render_help_1")}</strong></p>
<p><RawHtml html={t("render.note_detail_render_help_2")} /></p>
</Alert>
)}
<div ref={contentRef} className="note-detail-render-content" />
</>
);
}

View File

@@ -0,0 +1,11 @@
.note-detail-web-view {
height: 100%;
position: relative;
}
.note-detail-web-view > * {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,35 @@
import { t } from "../../services/i18n";
import utils from "../../services/utils";
import Alert from "../react/Alert";
import { useNoteLabel } from "../react/hooks";
import { TypeWidgetProps } from "./type_widget";
import "./WebView.css";
const isElectron = utils.isElectron();
export default function WebView({ note }: TypeWidgetProps) {
const [ webViewSrc ] = useNoteLabel(note, "webViewSrc");
return (webViewSrc
? <WebViewContent src={webViewSrc} />
: <WebViewHelp />
);
}
function WebViewContent({ src }: { src: string }) {
if (!isElectron) {
return <iframe src={src} class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups" />
} else {
return <webview src={src} class="note-detail-web-view-content" />
}
}
function WebViewHelp() {
return (
<Alert className="note-detail-web-view-help" type="warning" style={{ margin: "50px", padding: "20px 20px 0px 20px;" }}>
<h4>{t("web_view.web_view")}</h4>
<p>{t("web_view.embed_websites")}</p>
<p>{t("web_view.create_label")}</p>
</Alert>
)
}

View File

@@ -1,141 +0,0 @@
import { getThemeById } from "@triliumnext/codemirror";
import type FNote from "../../entities/fnote.js";
import options from "../../services/options.js";
import TypeWidget from "./type_widget.js";
import CodeMirror, { type EditorConfig } from "@triliumnext/codemirror";
import type { EventData } from "../../components/app_context.js";
export const DEFAULT_PREFIX = "default:";
/**
* An abstract {@link TypeWidget} which implements the CodeMirror editor, meant to be used as a parent for
* widgets requiring the editor.
*
* The widget handles the loading and initialization of the CodeMirror editor, as well as some common
* actions.
*
* The derived class must:
*
* - Define `$editor` in the constructor.
* - Call `super.doRender()` in the extended class.
* - Call `this._update(note, content)` in `#doRefresh(note)`.
*/
export default class AbstractCodeTypeWidget extends TypeWidget {
protected $editor!: JQuery<HTMLElement>;
protected codeEditor!: CodeMirror;
doRender() {
this.initialized = this.#initEditor();
}
async #initEditor() {
this.codeEditor = new CodeMirror({
parent: this.$editor[0],
lineWrapping: options.is("codeLineWrapEnabled"),
...this.getExtraOpts()
});
// Load the theme.
const themeId = options.get("codeNoteTheme");
if (themeId?.startsWith(DEFAULT_PREFIX)) {
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
if (theme) {
await this.codeEditor.setTheme(theme);
}
}
}
/**
* Can be extended in derived classes to add extra options to the CodeMirror constructor. The options are appended
* at the end, so it is possible to override the default values introduced by the abstract editor as well.
*
* @returns the extra options to be passed to the CodeMirror constructor.
*/
getExtraOpts(): Partial<EditorConfig> {
return {};
}
/**
* Called as soon as the CodeMirror library has been loaded and the editor was constructed. Can be extended in
* derived classes to add additional functionality or to register event handlers.
*
* By default, it does nothing.
*/
onEditorInitialized() {
// Do nothing by default.
}
/**
* Must be called by the derived classes in `#doRefresh(note)` in order to react to changes.
*
* @param the note that was changed.
* @param new content of the note.
*/
_update(note: { mime: string }, content: string) {
this.codeEditor.setText(content);
this.codeEditor.setMimeType(note.mime);
this.codeEditor.clearHistory();
}
show() {
this.$widget.show();
this.updateBackgroundColor();
}
focus() {
this.codeEditor.focus();
}
scrollToEnd() {
this.codeEditor.scrollToEnd();
this.codeEditor.focus();
}
cleanup() {
if (this.codeEditor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.codeEditor.setText("");
});
}
this.updateBackgroundColor("unset");
}
async executeWithCodeEditorEvent({ resolve, ntxId }: EventData<"executeWithCodeEditor">) {
if (!this.isNoteContext(ntxId)) {
return;
}
await this.initialized;
resolve(this.codeEditor);
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("codeNoteTheme")) {
const themeId = options.get("codeNoteTheme");
if (themeId?.startsWith(DEFAULT_PREFIX)) {
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
if (theme) {
await this.codeEditor.setTheme(theme);
}
this.updateBackgroundColor();
}
}
if (loadResults.isOptionReloaded("codeLineWrapEnabled")) {
this.codeEditor.setLineWrapping(options.is("codeLineWrapEnabled"));
}
}
updateBackgroundColor(color?: string) {
if (this.note?.mime === "text/x-sqlite;schema=trilium") {
// Don't apply a background color for SQL console notes.
return;
}
const $editorEl = $(this.codeEditor.dom);
this.$widget.closest(".scrolling-container").css("background-color", color ?? $editorEl.css("background-color"));
}
}

View File

@@ -1,284 +0,0 @@
import type FNote from "../../entities/fnote.js";
import utils from "../../services/utils.js";
import EditableCodeTypeWidget from "./editable_code.js";
import TypeWidget from "./type_widget.js";
import Split from "@triliumnext/split.js";
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer.js";
import options from "../../services/options.js";
import type { EventData } from "../../components/app_context.js";
import type OnClickButtonWidget from "../buttons/onclick_button.js";
import type { EditorConfig } from "@triliumnext/codemirror";
const TPL = /*html*/`\
<div class="note-detail-split note-detail-printable">
<div class="note-detail-split-editor-col">
<div class="note-detail-split-editor"></div>
<div class="admonition caution note-detail-error-container hidden-ext"></div>
</div>
<div class="note-detail-split-preview-col">
<div class="note-detail-split-preview"></div>
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group"></div>
</div>
<style>
.note-detail-split {
display: flex;
height: 100%;
}
.note-detail-split-editor-col {
display: flex;
flex-direction: column;
}
.note-detail-split-preview-col {
position: relative;
}
.note-detail-split .note-detail-split-editor {
width: 100%;
flex-grow: 1;
}
.note-detail-split .note-detail-split-editor .note-detail-code {
contain: size !important;
}
.note-detail-split .note-detail-error-container {
font-family: var(--monospace-font-family);
margin: 5px;
white-space: pre-wrap;
font-size: 0.85em;
}
.note-detail-split .note-detail-split-preview {
transition: opacity 250ms ease-in-out;
height: 100%;
}
.note-detail-split .note-detail-split-preview.on-error {
opacity: 0.5;
}
/* Horizontal layout */
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
border-inline-start: 1px solid var(--main-border-color);
}
.note-detail-split.split-horizontal > .note-detail-split-editor-col,
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
height: 100%;
width: 50%;
}
.note-detail-split.split-horizontal .note-detail-split-preview {
height: 100%;
}
/* Vertical layout */
.note-detail-split.split-vertical {
flex-direction: column;
}
.note-detail-split.split-vertical > .note-detail-split-editor-col,
.note-detail-split.split-vertical > .note-detail-split-preview-col {
width: 100%;
height: 50%;
}
.note-detail-split.split-vertical > .note-detail-split-editor-col {
border-top: 1px solid var(--main-border-color);
}
.note-detail-split.split-vertical .note-detail-split-preview-col {
order: -1;
}
/* Read-only view */
.note-detail-split.split-read-only .note-detail-split-preview-col {
width: 100%;
}
</style>
</div>
`;
/**
* Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen.
*
* Features:
*
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default abstract class AbstractSplitTypeWidget extends TypeWidget {
private splitInstance?: Split.Instance;
protected $preview!: JQuery<HTMLElement>;
private $editorCol!: JQuery<HTMLElement>;
private $previewCol!: JQuery<HTMLElement>;
private $editor!: JQuery<HTMLElement>;
private $errorContainer!: JQuery<HTMLElement>;
private editorTypeWidget: EditableCodeTypeWidget;
private layoutOrientation?: "horizontal" | "vertical";
private isReadOnly?: boolean;
constructor() {
super();
this.editorTypeWidget = new EditableCodeTypeWidget(true);
this.editorTypeWidget.updateBackgroundColor = () => {};
this.editorTypeWidget.isEnabled = () => true;
const defaultOptions = this.editorTypeWidget.getExtraOpts();
this.editorTypeWidget.getExtraOpts = () => {
return {
...defaultOptions,
...this.buildEditorExtraOptions()
};
};
}
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");
// Editor pane
this.$editorCol = this.$widget.find(".note-detail-split-editor-col");
this.$editor = this.$widget.find(".note-detail-split-editor");
this.$editor.append(this.editorTypeWidget.render());
this.$errorContainer = this.$widget.find(".note-detail-error-container");
this.#adjustLayoutOrientation();
// Preview pane buttons
const $previewButtons = this.$previewCol.find(".preview-buttons");
const previewButtons = this.buildPreviewButtons();
$previewButtons.toggle(previewButtons.length > 0);
for (const previewButton of previewButtons) {
const $button = previewButton.render();
$button.removeClass("button-widget")
.addClass("btn")
.addClass("tn-tool-button");
$previewButtons.append($button);
previewButton.refreshIcon();
}
super.doRender();
}
cleanup(): void {
this.#destroyResizer();
this.editorTypeWidget.cleanup();
}
async doRefresh(note: FNote) {
this.#adjustLayoutOrientation();
if (!this.isReadOnly) {
await this.editorTypeWidget.initialized;
this.editorTypeWidget.noteContext = this.noteContext;
this.editorTypeWidget.spacedUpdate = this.spacedUpdate;
this.editorTypeWidget.doRefresh(note);
}
}
#adjustLayoutOrientation() {
// Read-only
const isReadOnly = this.note?.hasLabel("readOnly");
if (this.isReadOnly !== isReadOnly) {
this.$editorCol.toggle(!isReadOnly);
}
// Vertical vs horizontal layout
const layoutOrientation = (!utils.isMobile() ? options.get("splitEditorOrientation") ?? "horizontal" : "vertical");
if (this.layoutOrientation !== layoutOrientation || this.isReadOnly !== isReadOnly) {
this.$widget
.toggleClass("split-horizontal", !isReadOnly && layoutOrientation === "horizontal")
.toggleClass("split-vertical", !isReadOnly && layoutOrientation === "vertical")
.toggleClass("split-read-only", isReadOnly);
this.layoutOrientation = layoutOrientation as ("horizontal" | "vertical");
this.isReadOnly = isReadOnly;
this.#destroyResizer();
}
if (!this.splitInstance) {
this.#setupResizer();
}
}
#setupResizer() {
if (!utils.isDesktop()) {
return;
}
let elements = [ this.$editorCol[0], this.$previewCol[0] ];
if (this.layoutOrientation === "vertical") {
elements.reverse();
}
this.splitInstance?.destroy();
if (!this.isReadOnly) {
this.splitInstance = Split(elements, {
rtl: glob.isRtl,
sizes: [ 50, 50 ],
direction: this.layoutOrientation,
gutterSize: DEFAULT_GUTTER_SIZE,
...this.buildSplitExtraOptions()
});
} else {
this.splitInstance = undefined;
}
}
#destroyResizer() {
this.splitInstance?.destroy();
this.splitInstance = undefined;
}
/**
* Called upon when the split between the preview and content pane is initialized. Can be used to add additional listeners if needed.
*/
buildSplitExtraOptions(): Split.Options {
return {};
}
/**
* Called upon when the code editor is being initialized. Can be used to add additional options to the editor.
*/
buildEditorExtraOptions(): Partial<EditorConfig> {
return {
lineWrapping: false
};
}
buildPreviewButtons(): OnClickButtonWidget[] {
return [];
}
setError(message: string | null | undefined) {
this.$errorContainer.toggleClass("hidden-ext", !message);
this.$preview.toggleClass("on-error", !!message);
this.$errorContainer.text(message ?? "");
}
getData() {
return this.editorTypeWidget.getData();
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
this.refresh();
}
}
}

View File

@@ -1,237 +0,0 @@
import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import toast from "../../services/toast.js";
import utils from "../../services/utils.js";
import ws from "../../services/ws.js";
import OnClickButtonWidget from "../buttons/onclick_button.js";
import AbstractSplitTypeWidget from "./abstract_split_type_widget.js";
/**
* A specialization of `SplitTypeWidget` meant for note types that have a SVG preview.
*
* This adds the following functionality:
*
* - Automatic handling of the preview when content or the note changes via {@link renderSvg}.
* - Built-in pan and zoom functionality with automatic re-centering.
* - Automatically displays errors to the user if {@link renderSvg} failed.
* - Automatically saves the SVG attachment.
*
*/
export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTypeWidget {
private $renderContainer!: JQuery<HTMLElement>;
private zoomHandler: () => void;
private zoomInstance?: SvgPanZoom.Instance;
private svg?: string;
constructor() {
super();
this.zoomHandler = () => {
if (this.zoomInstance) {
this.zoomInstance.resize();
this.zoomInstance.fit();
this.zoomInstance.center();
}
}
}
doRender(): void {
super.doRender();
this.$renderContainer = $(`<div>`)
.addClass("render-container")
.css("height", "100%");
this.$preview.append(this.$renderContainer);
$(window).on("resize", this.zoomHandler);
}
async doRefresh(note: FNote) {
super.doRefresh(note);
const blob = await note?.getBlob();
const content = blob?.content || "";
this.onContentChanged(content, true);
// Save the SVG when entering a note only when it does not have an attachment.
this.note?.getAttachments().then((attachments) => {
const attachmentName = `${this.attachmentName}.svg`;
if (!attachments.find((a) => a.title === attachmentName)) {
this.#saveSvg();
}
});
}
getData(): { content: string; } {
const data = super.getData();
this.onContentChanged(data.content, false);
this.#saveSvg();
return data;
}
/**
* Triggers an update of the preview pane with the provided content.
*
* @param content the content that will be passed to `renderSvg` for rendering. It is not the SVG content.
* @param recenter `true` to reposition the pan/zoom to fit the image and to center it.
*/
async onContentChanged(content: string, recenter: boolean) {
if (!this.note) {
return;
}
let svg: string = "";
try {
svg = await this.renderSvg(content);
// Rendering was succesful.
this.setError(null);
if (svg === this.svg) {
return;
}
this.svg = svg;
this.$renderContainer.html(svg);
} catch (e: unknown) {
// Rendering failed.
this.setError((e as Error)?.message);
}
await this.#setupPanZoom(!recenter);
}
#saveSvg() {
const payload = {
role: "image",
title: `${this.attachmentName}.svg`,
mime: "image/svg+xml",
content: this.svg,
position: 0
};
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload);
}
cleanup(): void {
this.#cleanUpZoom();
$(window).off("resize", this.zoomHandler);
super.cleanup();
}
/**
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
*
* The method must return a valid SVG string that will be automatically displayed in the preview.
*
* @param content the content of the note, in plain text.
*/
abstract renderSvg(content: string): Promise<string>;
/**
* Called to obtain the name of the note attachment (without .svg extension) that will be used for storing the preview.
*/
abstract get attachmentName(): string;
/**
* @param preservePanZoom `true` to keep the pan/zoom settings of the previous image, or `false` to re-center it.
*/
async #setupPanZoom(preservePanZoom: boolean) {
// Clean up
let pan: SvgPanZoom.Point | null = null;
let zoom: number | null = null;
if (preservePanZoom && this.zoomInstance) {
// Store pan and zoom for same note, when the user is editing the note.
pan = this.zoomInstance.getPan();
zoom = this.zoomInstance.getZoom();
this.#cleanUpZoom();
}
const $svgEl = this.$renderContainer.find("svg");
// Fit the image to bounds
$svgEl.attr("width", "100%")
.attr("height", "100%")
.css("max-width", "100%");
if (!$svgEl.length) {
return;
}
const svgPanZoom = (await import("svg-pan-zoom")).default;
const zoomInstance = svgPanZoom($svgEl[0], {
zoomEnabled: true,
controlIconsEnabled: false
});
if (preservePanZoom && pan && zoom) {
// Restore the pan and zoom.
zoomInstance.zoom(zoom);
zoomInstance.pan(pan);
} else {
// New instance, reposition properly.
zoomInstance.resize();
zoomInstance.center();
zoomInstance.fit();
}
this.zoomInstance = zoomInstance;
}
buildSplitExtraOptions(): Split.Options {
return {
onDrag: () => this.zoomHandler?.()
}
}
buildPreviewButtons(): OnClickButtonWidget[] {
return [
new OnClickButtonWidget()
.icon("bx-zoom-in")
.title(t("relation_map_buttons.zoom_in_title"))
.titlePlacement("top")
.onClick(() => this.zoomInstance?.zoomIn())
, new OnClickButtonWidget()
.icon("bx-zoom-out")
.title(t("relation_map_buttons.zoom_out_title"))
.titlePlacement("top")
.onClick(() => this.zoomInstance?.zoomOut())
, new OnClickButtonWidget()
.icon("bx-crop")
.title(t("relation_map_buttons.reset_pan_zoom_title"))
.titlePlacement("top")
.onClick(() => this.zoomHandler())
];
}
#cleanUpZoom() {
if (this.zoomInstance) {
this.zoomInstance.destroy();
this.zoomInstance = undefined;
}
}
async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
return;
}
utils.downloadSvg(this.note.title, this.svg);
}
async exportPngEvent({ ntxId }: EventData<"exportPng">) {
console.log("Export to PNG", this.noteContext?.noteId, ntxId, this.svg);
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
console.log("Return");
return;
}
try {
await utils.downloadSvgAsPng(this.note.title, this.svg);
} catch (e) {
console.warn(e);
toast.showError(t("svg.export_to_png"));
}
}
}

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