Compare commits

...

198 Commits

Author SHA1 Message Date
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
152 changed files with 6014 additions and 6760 deletions

View File

@@ -13,7 +13,6 @@ import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.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 { PromptDialogOptions } from "../widgets/dialogs/prompt.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 NoteTreeWidget from "../widgets/note_tree.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 TouchBarComponent from "./touch_bar.js";
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 type RootContainer from "../widgets/containers/root_container.js";
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 { TypeWidget } from "../widgets/note_types.jsx";
interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer;
@@ -199,7 +200,7 @@ export type CommandMappings = {
resetLauncher: ContextMenuCommandData;
executeInActiveNoteDetailWidget: CommandData & {
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
callback: (value: ReactWrappedWidget) => void;
};
executeWithTextEditor: CommandData &
ExecuteCommandData<CKTextEditor> & {
@@ -211,7 +212,7 @@ export type CommandMappings = {
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
*/
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
addTextToActiveEditor: CommandData & {
text: string;
};
@@ -222,8 +223,8 @@ export type CommandMappings = {
showPasswordNotSet: CommandData;
showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
showAddLinkDialog: CommandData & AddLinkOpts;
closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData;
@@ -484,13 +485,8 @@ type EventMappings = {
relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {};
showAddLinkDialog: {
textTypeWidget: EditableTextTypeWidget;
text: string;
};
showIncludeDialog: {
textTypeWidget: EditableTextTypeWidget;
};
showAddLinkDialog: AddLinkOpts;
showIncludeDialog: IncludeNoteOpts;
openBulkActionsDialog: {
selectedOrActiveNoteIds: string[];
};
@@ -665,6 +661,10 @@ export class AppContext extends Component {
this.beforeUnloadListeners.push(obj);
}
}
removeBeforeUnloadListener(listener: (() => boolean)) {
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l === listener);
}
}
const appContext = new AppContext(window.glob.isMainWindow);

View File

@@ -165,6 +165,7 @@ export default class Entrypoints extends Component {
return;
}
const { ntxId, note } = noteContext;
console.log("Run active note");
// ctrl+enter is also used elsewhere, so make sure we're running only when appropriate
if (!note || note.type !== "code") {

View File

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

View File

@@ -3,7 +3,6 @@ import TabRowWidget from "../widgets/tab_row.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteTitleWidget from "../widgets/note_title.jsx";
import NoteDetailWidget from "../widgets/note_detail.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteIconWidget from "../widgets/note_icon.jsx";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
@@ -42,6 +41,7 @@ import ApiLog from "../widgets/api_log.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
export default class DesktopLayout {
@@ -137,7 +137,7 @@ export default class DesktopLayout {
.filling()
.child(new PromotedAttributesWidget())
.child(<SqlTableSchemas />)
.child(new NoteDetailWidget())
.child(<NoteDetail />)
.child(<NoteList />)
.child(<SearchResult />)
.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 NoteIconWidget from "../widgets/note_icon";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@@ -65,7 +65,7 @@ export function applyModals(rootContainer: RootContainer) {
.child(<NoteTitleWidget />))
.child(<PopupEditorFormattingToolbar />)
.child(new PromotedAttributesWidget())
.child(new NoteDetailWidget())
.child(<NoteDetail />)
.child(<NoteList displayOnlyCollections />))
.child(<CallToActionDialog />);
}

View File

@@ -1,6 +1,5 @@
import FlexContainer from "../widgets/containers/flex_container.js";
import NoteTitleWidget from "../widgets/note_title.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
@@ -13,7 +12,7 @@ import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import type AppContext from "../components/app_context.js";
import TabRowWidget from "../widgets/tab_row.js";
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.js";
import { applyModals } from "./layout_commons.js";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import { useNoteContext } from "../widgets/react/hooks.jsx";
@@ -24,6 +23,7 @@ import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
const MOBILE_CSS = `
<style>
@@ -153,7 +153,7 @@ export default class MobileLayout {
new ScrollingContainer()
.filling()
.contentSized()
.child(new NoteDetailWidget())
.child(<NoteDetail />)
.child(<NoteList />)
.child(<FilePropertiesWrapper />)
)

View File

@@ -11,7 +11,7 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
import ws from "./ws.js";
import appContext from "../components/app_context.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 shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js";
@@ -19,7 +19,6 @@ import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import dayjs from "dayjs";
import type NoteContext from "../components/note_context.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
@@ -317,7 +316,7 @@ export interface Api {
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
* 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
*/

View File

@@ -1,6 +1,6 @@
import server from "./server.js";
import appContext, { type CommandNames } from "../components/app_context.js";
import shortcutService from "./shortcuts.js";
import appContext from "../components/app_context.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
import type Component from "../components/component.js";
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) {
const actions = await getActionsForScope(scope);
const bindings: ShortcutBinding[] = [];
for (const action of actions) {
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) => {

View File

@@ -279,7 +279,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.
* @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:")) {
return true;
}

View File

@@ -126,7 +126,7 @@ function downloadRevision(noteId: string, revisionId: string) {
/**
* @param url - should be without initial slash!!!
*/
function getUrlForDownload(url: string) {
export function getUrlForDownload(url: string) {
if (utils.isElectron()) {
// electron needs absolute URL, so we extract current host, port, protocol
return `${getHost()}/${url}`;

View File

@@ -3,7 +3,7 @@ import utils from "./utils.js";
type ElementType = HTMLElement | Document;
type Handler = (e: KeyboardEvent) => void;
interface ShortcutBinding {
export interface ShortcutBinding {
element: HTMLElement | Document;
shortcut: string;
handler: Handler;
@@ -126,10 +126,20 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
activeBindings.set(key, []);
}
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) {
const bindings = activeBindings.get(namespace);
if (bindings) {

View File

@@ -169,7 +169,7 @@ const entityMap: Record<string, string> = {
"=": "&#x3D;"
};
function escapeHtml(str: string) {
export function escapeHtml(str: string) {
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
}
@@ -869,6 +869,29 @@ export function getErrorMessage(e: unknown) {
}
}
// TODO: Deduplicate with server
export interface DeferredPromise<T> extends Promise<T> {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
// TODO: Deduplicate with server
export function deferred<T>(): DeferredPromise<T> {
return (() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
let promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
}) as DeferredPromise<T>;
promise.resolve = resolve;
promise.reject = reject;
return promise as DeferredPromise<T>;
})();
}
/**
* Handles left or right placement of e.g. tooltips in case of right-to-left languages. If the current language is a RTL one, then left and right are swapped. Other directions are unaffected.
* @param placement a string optionally containing a "left" or "right" value.

View File

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

View File

@@ -543,9 +543,14 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
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 {
height: 100%;
padding: 1em;
margin-bottom: 0;
}
.note-list-wrapper .note-book-card .bx {

View File

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

View File

@@ -984,7 +984,7 @@
},
"protected_session": {
"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.",
"wrong_password": "Passwort flasch.",
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",

View File

@@ -989,7 +989,7 @@
},
"protected_session": {
"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.",
"wrong_password": "Wrong password.",
"protecting-finished-successfully": "Protecting finished successfully.",

View File

@@ -987,7 +987,7 @@
},
"protected_session": {
"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.",
"wrong_password": "Contraseña incorrecta.",
"protecting-finished-successfully": "La protección finalizó exitosamente.",

View File

@@ -982,7 +982,7 @@
},
"protected_session": {
"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é.",
"wrong_password": "Mot de passe incorrect.",
"protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",

View File

@@ -968,7 +968,7 @@
},
"protected_session": {
"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.",
"wrong_password": "Palavra-passe incorreta.",
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.",

View File

@@ -1219,7 +1219,7 @@
"unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
"protecting-title": "Estado da 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": {
"open_in_new_tab": "Abrir em nova aba",

View File

@@ -979,7 +979,7 @@
},
"protected_session": {
"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ă.",
"wrong_password": "Parolă greșită.",
"protecting-finished-successfully": "Protejarea a avut succes.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ import { ViewTypeOptions } from "./collections/interface";
export interface FloatingButtonContext {
parentComponent: Component;
note: FNote;
note: FNote;
noteContext: NoteContext;
isDefaultViewMode: boolean;
isReadOnly: boolean;
@@ -65,11 +65,11 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
EditButton,
RelationMapButtons,
ExportImageButtons,
Backlinks
Backlinks
]
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
text={t("backend_log.refresh")}
icon="bx bx-refresh"
@@ -84,14 +84,14 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
return isEnabled && <FloatingButton
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
/>
}
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton
@@ -264,7 +264,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
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;
return isEnabled && (
@@ -325,7 +325,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0);
let [ popupOpen, setPopupOpen ] = useState(false);
const backlinksContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isDefaultViewMode) return;
@@ -338,7 +338,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
const { windowHeight } = useWindowSize();
useLayoutEffect(() => {
const el = backlinksContainerRef.current;
if (popupOpen && el) {
if (popupOpen && el) {
const box = el.getBoundingClientRect();
const maxHeight = windowHeight - box.top - 10;
el.style.maxHeight = `${maxHeight}px`;
@@ -374,7 +374,7 @@ function BacklinksList({ noteId }: { noteId: string }) {
.filter(bl => "noteId" in bl)
.map((bl) => bl.noteId);
await froca.getNotes(noteIds);
setBacklinks(backlinks);
setBacklinks(backlinks);
});
}, [ noteId ]);
@@ -395,4 +395,4 @@ function BacklinksList({ noteId }: { noteId: string }) {
)}
</div>
));
}
}

View File

@@ -0,0 +1,13 @@
.component.note-detail {
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
contain: none;
}
.note-detail.full-height {
height: 100%;
}
.note-detail > * {
contain: none;
}

View File

@@ -0,0 +1,276 @@
import { useNoteContext, useTriliumEvent, useTriliumEvents } 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, isMobile } from "../services/utils";
/**
* 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 ]);
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;
// Trigger in timeout to dismiss the menu while printing.
setTimeout(window.print, 0);
});
useTriliumEvent("exportAsPdf", () => {
if (!noteContext?.isActive() || !note) return;
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("export-as-pdf", {
title: note.title,
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(([ type, Element ]) => {
return <NoteDetailWrapper
Element={Element}
key={type}
type={type as ExtendedNoteType}
isVisible={activeNoteType === type}
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.
}
}, [ isVisible ]);
const typeMapping = TYPE_MAPPINGS[type];
return (
<div
className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""}`}
style={{
display: !isVisible ? "none" : "",
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> {
if (!note) {
console.log("Returning empty because no note.");
return "empty";
}
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

@@ -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 toastService from "../services/toast.js";
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> {
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 "./index.css";
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 { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
import dialog from "../../../services/dialog";

View File

@@ -1,7 +1,7 @@
import Map from "./map";
import "./index.css";
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 { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";

View File

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

View File

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

View File

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

View File

@@ -1,419 +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 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";
const TPL = /*html*/`
<div class="note-detail">
<style>
.note-detail {
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
}
.note-detail.full-height {
height: 100%;
}
</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();
}
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;
}
// Trigger in timeout to dismiss the menu while printing.
setTimeout(window.print, 0);
}
async exportAsPdfEvent() {
if (!this.noteContext?.isActive() || !this.note) {
return;
}
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("export-as-pdf", {
title: this.note.title,
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.
useEffect(() => {
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
appContext.addBeforeUnloadListener(listener);
return () => appContext.removeBeforeUnloadListener(listener);
}, []);
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());

View File

@@ -0,0 +1,142 @@
/**
* @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"),
className: "note-detail-canvas",
printable: true,
isFullHeight: true
},
relationMap: {
view: () => import("./type_widgets/relation_map/RelationMap"),
className: "note-detail-relation-map",
printable: 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

@@ -5,16 +5,18 @@ import keyboard_actions from "../../services/keyboard_actions";
export interface ActionButtonProps {
text: string;
titlePosition?: "bottom" | "left";
titlePosition?: "top" | "right" | "bottom" | "left";
icon: string;
className?: string;
onClick?: (e: MouseEvent) => void;
triggerCommand?: CommandNames;
noIconActionClass?: boolean;
frame?: boolean;
active?: boolean;
disabled?: boolean;
}
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame }: ActionButtonProps) {
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled }: ActionButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
@@ -32,8 +34,9 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
return <button
ref={buttonRef}
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""}`}
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""} ${disabled ? "disabled" : ""} ${active ? "active" : ""}`}
onClick={onClick}
data-trigger-command={triggerCommand}
disabled={disabled}
/>;
}

View File

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

View File

@@ -18,15 +18,18 @@ export default function FormFileUpload({ inputRef, name, onChange, multiple, hid
name={name}
type="file"
class="form-control-file"
multiple={multiple}
onChange={e => onChange((e.target as HTMLInputElement).files)} />
multiple={multiple}
onChange={e => {
onChange((e.target as HTMLInputElement).files);
e.currentTarget.value = "";
}} />
</label>
)
}
/**
* Combination of a button with a hidden file upload field.
*
*
* @param param the change listener for the file upload and the properties for the button.
*/
export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
@@ -39,10 +42,10 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonPr
onClick={() => inputRef.current?.click()}
/>
<FormFileUpload
inputRef={inputRef}
inputRef={inputRef}
hidden
onChange={onChange}
/>
</>
)
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "preact/hooks";
import link from "../../services/link";
import link, { ViewScope } from "../../services/link";
import { useImperativeSearchHighlighlighting } from "./hooks";
interface NoteLinkOpts {
@@ -11,18 +11,26 @@ interface NoteLinkOpts {
noPreview?: boolean;
noTnLink?: boolean;
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 ref = useRef<HTMLSpanElement>(null);
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
useEffect(() => {
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
.then(setJqueryEl);
}, [ stringifiedNotePath, showNotePath ]);
link.createLink(stringifiedNotePath, {
title,
showNotePath,
showNoteIcon,
viewScope
}).then(setJqueryEl);
}, [ stringifiedNotePath, showNotePath, title, viewScope ]);
useEffect(() => {
if (!ref.current || !jqueryEl) return;
@@ -43,6 +51,10 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
$linkEl?.addClass("tn-link");
}
if (noContextMenu) {
$linkEl?.attr("data-no-context-menu", "true");
}
if (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;
click: () => void;
enabled?: boolean;
backgroundColor?: string;
}
interface SpacerProps {
@@ -129,13 +130,14 @@ export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: S
return <></>;
}
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) {
export function TouchBarButton({ label, icon, click, enabled, backgroundColor }: ButtonProps) {
const api = useContext(TouchBarContext);
const item = useMemo(() => {
if (!api) return null;
return new api.TouchBar.TouchBarButton({
label, click, enabled,
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
icon: icon ? buildIcon(api.nativeImage, icon) : undefined,
backgroundColor
});
}, [ label, icon ]);
@@ -171,6 +173,32 @@ export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChan
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) {
const api = useContext(TouchBarContext);

View File

@@ -1,6 +1,6 @@
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { CommandListenerData, EventData, EventNames } from "../../components/app_context";
import { ParentComponent } from "./react_utils";
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext, { EventData, EventNames } from "../../components/app_context";
import { ParentComponent, refToJQuerySelector } from "./react_utils";
import SpacedUpdate from "../../services/spaced_update";
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
import options, { type OptionValue } from "../../services/options";
@@ -19,7 +19,10 @@ import Mark from "mark.js";
import { DragData } from "../note_tree";
import Component from "../../components/component";
import toast, { ToastOptions } from "../../services/toast";
import { ViewMode } from "../../services/link";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
import { removeIndividualBinding } from "../../services/shortcuts";
import { ViewScope } from "../../services/link";
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
const parentComponent = useContext(ParentComponent);
@@ -74,6 +77,66 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
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.
*
@@ -197,7 +260,7 @@ export function useNoteContext() {
const [ noteContext, setNoteContext ] = useState<NoteContext>();
const [ notePath, setNotePath ] = useState<string | null | undefined>();
const [ note, setNote ] = useState<FNote | null | undefined>();
const [ , setViewMode ] = useState<ViewMode>();
const [ , setViewScope ] = useState<ViewScope>();
const [ refreshCounter, setRefreshCounter ] = useState(0);
useEffect(() => {
@@ -207,7 +270,7 @@ export function useNoteContext() {
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
setNoteContext(noteContext);
setNotePath(noteContext.notePath);
setViewMode(noteContext.viewScope?.viewMode);
setViewScope(noteContext.viewScope);
});
useTriliumEvent("frocaReloaded", () => {
setNote(noteContext?.note);
@@ -373,7 +436,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>();
function refresh() {
@@ -394,6 +457,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde
if (loadResults.hasRevisionForNote(note.noteId)) {
refresh();
}
if (loadResults.isNoteContentReloaded(note.noteId, componentId)) {
refresh();
}
});
useDebugValue(note?.noteId);
@@ -667,26 +734,6 @@ export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | un
}, [ 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) {
const resizeObserver = useRef<ResizeObserver>(null);
useEffect(() => {
@@ -701,3 +748,17 @@ export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => v
return () => observer.disconnect();
}, [ 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);
}
}
}, []);
}

View File

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

View File

@@ -45,13 +45,13 @@ function RevisionsButton({ note }: { note: FNote }) {
function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
const parentComponent = useContext(ParentComponent);
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(note.type);
const isSearchable = ["text", "code", "book", "mindMap", "doc", "render"].includes(note.type);
const isInOptions = note.noteId.startsWith("_options");
const isPrintable = ["text", "code"].includes(note.type);
const isElectron = getIsElectron();
const isMac = getIsMac();
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type);
const isSearchOrBook = ["search", "book"].includes(note.type);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
const isSearchOrBook = ["search", "book"].includes(note.type);
return (
<Dropdown
@@ -74,7 +74,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
<CommandItem icon="bx bx-export" text={t("note_actions.export_note")}
disabled={isInOptions || note.noteId === "_backendLog"}
command={() => noteContext?.notePath && parentComponent?.triggerCommand("showExportDialog", {
notePath: noteContext.notePath,
notePath: noteContext.notePath,
defaultType: "single"
})} />
<FormDropdownDivider />
@@ -133,4 +133,4 @@ function ConvertToAttachment({ note }: { note: FNote }) {
}}
>{t("note_actions.convert_into_attachment")}</FormListItem>
)
}
}

View File

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

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } 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);
console.log("Refresh!");
}, [ 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,303 @@
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);
useEffect(() => {
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
.then(({ $renderedContent }) => {
contentWrapper.current?.replaceChildren(...$renderedContent);
})
}, [ attachment ]);
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,34 @@
.excalidraw .App-menu_top .buttonList {
display: flex;
}
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
/* https://github.com/zadam/trilium/issues/3780 */
/* https://github.com/excalidraw/excalidraw/issues/6567 */
.excalidraw .dropdown-menu {
display: block;
}
.excalidraw-wrapper {
height: 100%;
}
:root[dir="ltr"]
.excalidraw
.layer-ui__wrapper
.zen-mode-transition.App-menu_bottom--transition-left {
transform: none;
}
/* collaboration not possible so hide the button */
.CollabButton {
display: none !important;
}
.library-button {
display: none !important; /* library won't work without extra support which isn't currently implemented */
}
.note-detail-canvas > .canvas-render {
height: 100%;
}

View File

@@ -0,0 +1,325 @@
import { Excalidraw, exportToSvg, getSceneVersion } from "@excalidraw/excalidraw";
import { TypeWidgetProps } from "./type_widget";
import "@excalidraw/excalidraw/index.css";
import { useEditorSpacedUpdate, useNoteLabelBoolean } from "../react/hooks";
import { useCallback, useMemo, useRef } from "preact/hooks";
import { type ExcalidrawImperativeAPI, type AppState, type BinaryFileData, LibraryItem, ExcalidrawProps } from "@excalidraw/excalidraw/types";
import options from "../../services/options";
import "./Canvas.css";
import FNote from "../../entities/fnote";
import { RefObject } from "preact";
import server from "../../services/server";
import { ExcalidrawElement, NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { goToLinkExt } from "../../services/link";
import NoteContext from "../../components/note_context";
// currently required by excalidraw, in order to allows self-hosting fonts locally.
// this avoids making excalidraw load the fonts from an external CDN.
window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
interface AttachmentMetadata {
title: string;
attachmentId: string;
}
interface CanvasContent {
elements: ExcalidrawElement[];
files: BinaryFileData[];
appState: Partial<AppState>;
}
export default function Canvas({ note, noteContext }: TypeWidgetProps) {
const apiRef = useRef<ExcalidrawImperativeAPI>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const themeStyle = useMemo(() => {
const documentStyle = window.getComputedStyle(document.documentElement);
return documentStyle.getPropertyValue("--theme-style")?.trim() as AppState["theme"];
}, []);
const persistence = usePersistence(note, noteContext, apiRef, themeStyle, isReadOnly);
/** Use excalidraw's native zoom instead of the global zoom. */
const onWheel = useCallback((e: MouseEvent) => {
if (e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
}
}, []);
const onLinkOpen = useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
let link = element.link;
if (!link) {
return false;
}
if (link.startsWith("root/")) {
link = "#" + link;
}
const { nativeEvent } = event.detail;
event.preventDefault();
return goToLinkExt(nativeEvent, link, null);
}, []);
return (
<div className="canvas-render" onWheel={onWheel}>
<div className="excalidraw-wrapper">
<Excalidraw
excalidrawAPI={api => apiRef.current = api}
theme={themeStyle}
viewModeEnabled={isReadOnly || options.is("databaseReadonly")}
zenModeEnabled={false}
isCollaborating={false}
detectScroll={false}
handleKeyboardGlobally={false}
autoFocus={false}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
export: false
}
}}
onLinkOpen={onLinkOpen}
{...persistence}
/>
</div>
</div>
)
}
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"], isReadOnly: boolean): Partial<ExcalidrawProps> {
const libraryChanged = useRef(false);
/**
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
* we compare the scene version as suggested in:
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
*
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
*/
const currentSceneVersion = useRef(0);
// these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted.
//every libraryitem is saved on its own json file in the attachments of the note.
const libraryCache = useRef<LibraryItem[]>([]);
const attachmentMetadata = useRef<AttachmentMetadata[]>([]);
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
onContentChange(newContent) {
const api = apiRef.current;
if (!api) return;
libraryCache.current = [];
attachmentMetadata.current = [];
currentSceneVersion.current = -1;
// load saved content into excalidraw canvas
let content: CanvasContent = {
elements: [],
files: [],
appState: {}
};
if (newContent) {
try {
content = JSON.parse(newContent) as CanvasContent;
} catch (err) {
console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, err);
}
}
loadData(api, content, theme);
// load the library state
loadLibrary(note).then(({ libraryItems, metadata }) => {
// Update the library and save to independent variables
api.updateLibrary({ libraryItems: libraryItems, merge: false });
// save state of library to compare it to the new state later.
libraryCache.current = libraryItems;
attachmentMetadata.current = metadata;
});
},
async getData() {
const api = apiRef.current;
if (!api) return;
const { content, svg } = await getData(api);
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
// libraryChanged is unset in dataSaved()
if (libraryChanged.current) {
// there's no separate method to get library items, so have to abuse this one
const libraryItems = await api.updateLibrary({
libraryItems() {
return [];
},
merge: true
});
// excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note.
//We need the cache to delete old attachments later in the server.
const libraryItemsMissmatch = libraryCache.current.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id));
// before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name.
// we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch)
// then we combine its id and title and search the according attachmentID.
const matchingItems = attachmentMetadata.current.filter((meta) => {
// Loop through the second array and check for a match
return libraryItemsMissmatch.some((item) => {
// Combine the `name` and `id` from the second array
const combinedTitle = `${item.id}${item.name}`;
return meta.title === combinedTitle;
});
});
// we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted.
const attachmentIds = matchingItems.map((item) => item.attachmentId);
//delete old attachments that are no longer used
for (const item of attachmentIds) {
await server.remove(`attachments/${item}`);
}
let position = 10;
// prepare data to save to server e.g. new library items.
for (const libraryItem of libraryItems) {
attachments.push({
role: "canvasLibraryItem",
title: libraryItem.id + libraryItem.name,
mime: "application/json",
content: JSON.stringify(libraryItem),
position: position
});
position += 10;
}
}
return {
content: JSON.stringify(content),
attachments
};
},
dataSaved() {
libraryChanged.current = false;
}
});
return {
onChange: () => {
if (!apiRef.current || isReadOnly) return;
const oldSceneVersion = currentSceneVersion.current;
const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements());
if (newSceneVersion !== oldSceneVersion) {
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
currentSceneVersion.current = newSceneVersion;
}
},
onLibraryChange: () => {
libraryChanged.current = true;
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
}
}
}
async function getData(api: ExcalidrawImperativeAPI) {
const elements = api.getSceneElements();
const appState = api.getAppState();
/**
* A file is not deleted, even though removed from canvas. Therefore, we only keep
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
*/
const files = api.getFiles();
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
const svg = await exportToSvg({
elements,
appState,
exportPadding: 5, // 5 px padding
files
});
const svgString = svg.outerHTML;
const activeFiles: Record<string, BinaryFileData> = {};
elements.forEach((element: NonDeletedExcalidrawElement) => {
if ("fileId" in element && element.fileId) {
activeFiles[element.fileId] = files[element.fileId];
}
});
const content = {
type: "excalidraw",
version: 2,
elements,
files: activeFiles,
appState: {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
gridModeEnabled: appState.gridModeEnabled
}
};
return {
content,
svg: svgString
}
}
function loadData(api: ExcalidrawImperativeAPI, content: CanvasContent, theme: AppState["theme"]) {
const { elements, files } = content;
const appState: Partial<AppState> = content.appState ?? {};
appState.theme = theme;
// files are expected in an array when loading. they are stored as a key-index object
// see example for loading here:
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
const fileArray: BinaryFileData[] = [];
for (const fileId in files) {
const file = files[fileId];
// TODO: dataURL is replaceable with a trilium image url
// maybe we can save normal images (pasted) with base64 data url, and trilium images
// with their respective url! nice
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
fileArray.push(file);
}
// Update the scene
// TODO: Fix type of sceneData
api.updateScene({
elements,
appState: appState as AppState
});
api.addFiles(fileArray);
api.history.clear();
}
async function loadLibrary(note: FNote) {
return Promise.all(
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
const blob = await attachment.getBlob();
return {
blob, // Save the blob for libraryItems
metadata: {
// metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly
attachmentId: attachment.attachmentId,
title: attachment.title
}
};
})
).then((results) => {
// Extract libraryItems from the blobs
const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[];
// Extract metadata for each attachment
const metadata = results.map((result) => result.metadata);
return { libraryItems, metadata };
});
}

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,36 @@
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}) => {
console.log("Got request for content ", 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 { useCallback, 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,79 @@
import { VNode } from "preact";
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 { useNoteBlob, 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 { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js";
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js";
import { useCallback } from "preact/hooks";
import SvgSplitEditor from "./helpers/SvgSplitEditor";
import { TypeWidgetProps } from "./type_widget";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
let idCounter = 1;
let registeredErrorReporter = false;
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
static getType() {
return "mermaid";
}
get attachmentName(): string {
return "mermaid-export";
}
async renderSvg(content: string) {
export default function Mermaid(props: TypeWidgetProps) {
const renderSvg = useCallback(async (content: string) => {
const mermaid = (await import("mermaid")).default;
await loadElkIfNeeded(mermaid, content);
if (!registeredErrorReporter) {
@@ -31,6 +23,13 @@ export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
idCounter++;
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
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,161 @@
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, useTriliumOptionBool } 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;
content: MindElixirData;
onChange?: () => void;
}
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const content = VanillaMindElixir.new(NEW_TOPIC_NAME);
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}
content={content}
onChange={() => spacedUpdate.scheduleUpdate()}
editable={!isReadOnly}
containerProps={{
className: "mind-map-container",
onKeyDown
}}
/>
)
}
function MindElixir({ content, containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) {
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
const apiRef = useRef<MindElixirInstance>(null);
useEffect(() => {
if (!containerRef.current) return;
const mind = new VanillaMindElixir({
el: containerRef.current,
editable
});
if (editable) {
mind.install(nodeMenu);
}
mind.init(content);
apiRef.current = mind;
if (externalApiRef) {
externalApiRef.current = mind;
}
return () => mind.destroy();
}, [ 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,283 +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 "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, {
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"));
}
}
}

View File

@@ -1,135 +0,0 @@
import TypeWidget from "./type_widget.js";
import appContext, { type EventData } from "../../components/app_context.js";
import froca from "../../services/froca.js";
import linkService, { type ViewScope } from "../../services/link.js";
import contentRenderer from "../../services/content_renderer.js";
import utils from "../../services/utils.js";
import options from "../../services/options.js";
import attributes from "../../services/attributes.js";
export default class AbstractTextTypeWidget extends TypeWidget {
doRender() {
super.doRender();
this.refreshCodeBlockOptions();
}
setupImageOpening(singleClickOpens: boolean) {
this.$widget.on("dblclick", "img", (e) => this.openImageInCurrentTab($(e.target)));
this.$widget.on("click", "img", (e) => {
e.stopPropagation();
const isLeftClick = e.which === 1;
const isMiddleClick = e.which === 2;
const ctrlKey = utils.isCtrlKey(e);
const activate = (isLeftClick && ctrlKey && e.shiftKey) || (isMiddleClick && e.shiftKey);
if ((isLeftClick && ctrlKey) || isMiddleClick) {
this.openImageInNewTab($(e.target), activate);
} else if (isLeftClick && singleClickOpens) {
this.openImageInCurrentTab($(e.target));
}
});
}
async openImageInCurrentTab($img: JQuery<HTMLElement>) {
const parsedImage = await this.parseFromImage($img);
if (parsedImage) {
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
} else {
window.open($img.prop("src"), "_blank");
}
}
async openImageInNewTab($img: JQuery<HTMLElement>, activate: boolean = false) {
const parsedImage = await this.parseFromImage($img);
if (parsedImage?.noteId) {
appContext.tabManager.openTabWithNoteWithHoisting(parsedImage.noteId, { activate, viewScope: parsedImage.viewScope });
} else {
window.open($img.prop("src"), "_blank");
}
}
async parseFromImage($img: JQuery<HTMLElement>): Promise<{ noteId?: string, viewScope: ViewScope } | null> {
const imgSrc = $img.prop("src");
const imageNoteMatch = imgSrc.match(/\/api\/images\/([A-Za-z0-9_]+)\//);
if (imageNoteMatch) {
return {
noteId: imageNoteMatch[1],
viewScope: {}
};
}
const attachmentMatch = imgSrc.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//);
if (attachmentMatch) {
const attachmentId = attachmentMatch[1];
const attachment = await froca.getAttachment(attachmentId);
return {
noteId: attachment?.ownerId,
viewScope: {
viewMode: "attachments",
attachmentId: attachmentId
}
};
}
return null;
}
async loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>) {
const note = await froca.getNote(noteId);
if (note) {
const $wrapper = $('<div class="include-note-wrapper">');
const $link = await linkService.createLink(note.noteId, {
showTooltip: false
});
$wrapper.empty().append($('<h4 class="include-note-title">').append($link));
const { $renderedContent, type } = await contentRenderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
$el.empty().append($wrapper);
}
}
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null = null) {
await linkService.loadReferenceLinkTitle($el, href);
}
refreshIncludedNote($container: JQuery<HTMLElement>, noteId: string) {
if ($container) {
$container.find(`section[data-note-id="${noteId}"]`).each((_, el) => {
this.loadIncludedNote(noteId, $(el));
});
}
}
refreshCodeBlockOptions() {
const wordWrap = options.is("codeBlockWordWrap");
this.$widget.toggleClass("word-wrap", wordWrap);
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("codeBlockWordWrap")) {
this.refreshCodeBlockOptions();
}
if (loadResults.getAttributeRows().find((attr) =>
attr.type === "label" &&
attr.name === "language" &&
attributes.isAffecting(attr, this.note)))
{
await this.onLanguageChanged();
}
}
async onLanguageChanged() { }
}

View File

@@ -1,274 +0,0 @@
import TypeWidget from "./type_widget.js";
import LlmChatPanel from "../llm_chat_panel.js";
import { type EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
export default class AiChatTypeWidget extends TypeWidget {
private llmChatPanel: LlmChatPanel;
private isInitialized: boolean = false;
private initPromise: Promise<void> | null = null;
constructor() {
super();
this.llmChatPanel = new LlmChatPanel();
// Connect the data callbacks
this.llmChatPanel.setDataCallbacks(
(data) => this.saveData(data),
() => this.getData()
);
}
static getType() {
return "aiChat";
}
doRender() {
this.$widget = $('<div class="ai-chat-widget-container" style="height: 100%;"></div>');
this.$widget.append(this.llmChatPanel.render());
return this.$widget;
}
// Override the refreshWithNote method to ensure we get note changes
async refreshWithNote(note: FNote | null | undefined) {
console.log("refreshWithNote called for note:", note?.noteId);
// Always force a refresh when the note changes
if (this.note?.noteId !== note?.noteId) {
console.log(`Note ID changed from ${this.note?.noteId} to ${note?.noteId}, forcing reset`);
this.isInitialized = false;
this.initPromise = null;
// Force refresh the chat panel with the new note
if (note) {
this.llmChatPanel.setCurrentNoteId(note.noteId);
}
}
// Continue with regular doRefresh
await this.doRefresh(note);
}
async doRefresh(note: FNote | null | undefined) {
try {
console.log("doRefresh called for note:", note?.noteId);
// If we're already initializing, wait for that to complete
if (this.initPromise) {
await this.initPromise;
return;
}
// Initialize once or when note changes
if (!this.isInitialized) {
console.log("Initializing AI Chat Panel for note:", note?.noteId);
// Initialize the note content first
if (note) {
try {
const content = await note.getContent();
// Check if content is empty
if (!content || content === '{}') {
// Initialize with empty chat history
await this.saveData({
messages: [],
title: note.title,
noteId: note.noteId // Store the note ID in the data
});
console.log("Initialized empty chat history for new note");
} else {
console.log("Note already has content, will load in LlmChatPanel.refresh()");
}
} catch (e) {
console.error("Error initializing AI Chat note content:", e);
}
}
// Create a promise to track initialization
this.initPromise = (async () => {
try {
// Reset the UI before refreshing
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
// Set the note ID for the chat panel
if (note) {
this.llmChatPanel.setNoteId(note.noteId);
}
// This will load saved data via the getData callback
await this.llmChatPanel.refresh();
this.isInitialized = true;
} catch (e) {
console.error("Error initializing LlmChatPanel:", e);
toastService.showError("Failed to initialize chat panel. Try reloading.");
}
})();
await this.initPromise;
this.initPromise = null;
}
} catch (e) {
console.error("Error in doRefresh:", e);
toastService.showError("Error refreshing chat. Please try again.");
}
}
async entitiesReloadedEvent(data: EventData<"entitiesReloaded">) {
// We don't need to refresh on entities reloaded for the chat
}
async noteSwitched() {
console.log("Note switched to:", this.noteId);
// Force a full reset when switching notes
this.isInitialized = false;
this.initPromise = null;
if (this.note) {
// Update the chat panel with the new note ID before refreshing
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
// Reset the chat panel UI
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
this.llmChatPanel.setNoteId(this.note.noteId);
}
// Call the parent method to refresh
await super.noteSwitched();
}
async activeContextChangedEvent(data: EventData<"activeContextChanged">) {
if (!this.isActive()) {
return;
}
console.log("Active context changed, refreshing AI Chat Panel");
// Always refresh when we become active - this ensures we load the correct note data
try {
// Reset initialization flag to force a refresh
this.isInitialized = false;
// Make sure the chat panel has the current note ID
if (this.note) {
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
this.llmChatPanel.setNoteId(this.note.noteId);
}
this.initPromise = (async () => {
try {
// Reset the UI before refreshing
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
await this.llmChatPanel.refresh();
this.isInitialized = true;
} catch (e) {
console.error("Error refreshing LlmChatPanel:", e);
}
})();
await this.initPromise;
this.initPromise = null;
} catch (e) {
console.error("Error in activeContextChangedEvent:", e);
}
}
// Save chat data to the note
async saveData(data: any) {
// If we have a noteId in the data, that's the AI Chat note we should save to
// This happens when the chat panel is saving its conversation
const targetNoteId = data.noteId;
// If no noteId in data, use the current note (for new chats)
const noteIdToUse = targetNoteId || this.note?.noteId;
if (!noteIdToUse) {
console.warn("Cannot save AI Chat data: no note ID available");
return;
}
try {
console.log(`AiChatTypeWidget: Saving data for note ${noteIdToUse} (current note: ${this.note?.noteId}, data.noteId: ${data.noteId})`);
// Safety check: if we have both IDs and they don't match, warn about it
if (targetNoteId && this.note?.noteId && targetNoteId !== this.note.noteId) {
console.warn(`Note ID mismatch: saving to ${targetNoteId} but current note is ${this.note.noteId}`);
}
// Format the data properly - this is the canonical format of the data
const formattedData = {
messages: data.messages || [],
noteId: noteIdToUse, // Always preserve the correct note ID
toolSteps: data.toolSteps || [],
sources: data.sources || [],
metadata: {
...(data.metadata || {}),
lastUpdated: new Date().toISOString()
}
};
// Save the data to the correct note
await server.put(`notes/${noteIdToUse}/data`, {
content: JSON.stringify(formattedData, null, 2)
});
} catch (e) {
console.error("Error saving AI Chat data:", e);
toastService.showError("Failed to save chat data");
}
}
// Get data from the note
async getData() {
if (!this.note) {
return null;
}
try {
console.log(`AiChatTypeWidget: Getting data for note ${this.note.noteId}`);
const content = await this.note.getContent();
if (!content) {
console.log("Note content is empty");
return null;
}
// Parse the content as JSON
let parsedContent;
try {
parsedContent = JSON.parse(content as string);
console.log("Successfully parsed note content as JSON");
} catch (e) {
console.error("Error parsing chat content as JSON:", e);
return null;
}
// Check if this is a blob response with 'content' property that needs to be parsed again
// This happens when the content is returned from the /blob endpoint
if (parsedContent.content && typeof parsedContent.content === 'string' &&
parsedContent.blobId && parsedContent.contentLength) {
try {
// The actual chat data is inside the 'content' property as a string
console.log("Detected blob response structure, parsing inner content");
const innerContent = JSON.parse(parsedContent.content);
console.log("Successfully parsed blob inner content");
return innerContent;
} catch (innerError) {
console.error("Error parsing inner blob content:", innerError);
return null;
}
}
return parsedContent;
} catch (e) {
console.error("Error loading AI Chat data:", e);
return null;
}
}
}

View File

@@ -1,101 +0,0 @@
import TypeWidget from "./type_widget.js";
import AttachmentDetailWidget from "../attachment_detail.js";
import linkService from "../../services/link.js";
import froca from "../../services/froca.js";
import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="attachment-detail note-detail-printable">
<style>
.attachment-detail {
padding-inline-start: 15px;
padding-inline-end: 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;
}
</style>
<div class="links-wrapper use-tn-links"></div>
<div class="attachment-wrapper"></div>
</div>`;
export default class AttachmentDetailTypeWidget extends TypeWidget {
$wrapper!: JQuery<HTMLElement>;
$linksWrapper!: JQuery<HTMLElement>;
static getType() {
return "attachmentDetail";
}
doRender() {
this.$widget = $(TPL);
this.$wrapper = this.$widget.find(".attachment-wrapper");
this.$linksWrapper = this.$widget.find(".links-wrapper");
super.doRender();
}
async doRefresh(note: Parameters<TypeWidget["doRefresh"]>[0]) {
this.$wrapper.empty();
this.children = [];
const $helpButton = $(`
<button class="attachment-help-button icon-action bx bx-help-circle"
type="button" data-help-page="attachments.html"
title="${t("attachment_detail.open_help_page")}"
</button>
`);
utils.initHelpButtons($helpButton);
this.$linksWrapper.empty().append(
t("attachment_detail.owning_note"),
await linkService.createLink(this.noteId),
t("attachment_detail.you_can_also_open"),
await linkService.createLink(this.noteId, {
title: t("attachment_detail.list_of_all_attachments"),
viewScope: {
viewMode: "attachments"
}
}),
$helpButton
);
const attachment = this.attachmentId ? await froca.getAttachment(this.attachmentId, true) : null;
if (!attachment) {
this.$wrapper.html("<strong>" + t("attachment_detail.attachment_deleted") + "</strong>");
return;
}
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, true);
this.child(attachmentDetailWidget);
this.$wrapper.append(attachmentDetailWidget.render());
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachmentId);
if (attachmentRow?.isDeleted) {
this.refresh(); // all other updates are handled within AttachmentDetailWidget
}
}
get attachmentId() {
return this?.noteContext?.viewScope?.attachmentId;
}
}

View File

@@ -1,107 +0,0 @@
import TypeWidget from "./type_widget.js";
import AttachmentDetailWidget from "../attachment_detail.js";
import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="attachment-list note-detail-printable">
<style>
.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;
}
</style>
<div class="links-wrapper"></div>
<div class="attachment-list-wrapper"></div>
</div>`;
export default class AttachmentListTypeWidget extends TypeWidget {
$list!: JQuery<HTMLElement>;
$linksWrapper!: JQuery<HTMLElement>;
renderedAttachmentIds!: Set<string>;
static getType() {
return "attachmentList";
}
doRender() {
this.$widget = $(TPL);
this.$list = this.$widget.find(".attachment-list-wrapper");
this.$linksWrapper = this.$widget.find(".links-wrapper");
super.doRender();
}
async doRefresh(note: Parameters<TypeWidget["doRefresh"]>[0]) {
const $helpButton = $(`
<button class="attachment-help-button icon-action bx bx-help-circle"
type="button" data-help-page="attachments.html"
title="${t("attachment_list.open_help_page")}">
</button>
`);
utils.initHelpButtons($helpButton);
const noteLink = await linkService.createLink(this.noteId); // do separately to avoid race condition between empty() and .append()
noteLink.addClass("use-tn-links");
const $uploadButton = $(`
<button class="btn btn-sm">
<span class="bx bx-folder-open"></span>
${t("attachment_list.upload_attachments")}
</button>
`);
$uploadButton.on("click", () => {
if (this.noteId) {
this.triggerCommand("showUploadAttachmentsDialog", { noteId: this.noteId });
}
})
this.$linksWrapper.empty().append(
$("<div>").append(t("attachment_list.owning_note"), noteLink),
$(`<div class="attachment-actions-toolbar">`).append($uploadButton, $helpButton)
);
this.$list.empty();
this.children = [];
this.renderedAttachmentIds = new Set();
const attachments = await note.getAttachments();
if (attachments.length === 0) {
this.$list.html('<div class="alert alert-info">' + t("attachment_list.no_attachments") + "</div>");
return;
}
for (const attachment of attachments) {
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
this.child(attachmentDetailWidget);
this.renderedAttachmentIds.add(attachment.attachmentId);
this.$list.append(attachmentDetailWidget.render());
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId));
if (attachmentsAdded) {
this.refresh();
}
}
}

View File

@@ -1,62 +0,0 @@
import TypeWidget from "./type_widget.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="note-detail-book note-detail-printable">
<style>
.note-detail-book-auto-help {
background-color: var(--accented-background-color);
text-align: center;
border-radius: 10px;
padding: 5px;
margin: 0 10px 10px 10px;
}
</style>
<div class="note-detail-book-empty-help alert alert-warning" style="margin: 50px; padding: 20px;">
${t("book.no_children_help")}
</div>
</div>`;
export default class BookTypeWidget extends TypeWidget {
private $helpNoChildren!: JQuery<HTMLElement>;
static getType() {
return "book";
}
doRender() {
this.$widget = $(TPL);
this.$helpNoChildren = this.$widget.find(".note-detail-book-empty-help");
super.doRender();
}
async doRefresh(note: FNote) {
this.$helpNoChildren.toggle(this.shouldDisplayNoChildrenWarning());
}
shouldDisplayNoChildrenWarning() {
if (this.note?.hasChildren()) {
return false;
}
switch (this.note?.getAttributeValue("label", "viewType")) {
case "list":
case "grid":
return true;
default:
return false;
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
this.refresh();
}
}
}

View File

@@ -1,421 +0,0 @@
import TypeWidget from "./type_widget.js";
import server from "../../services/server.js";
import type FNote from "../../entities/fnote.js";
import options from "../../services/options.js";
import type { LibraryItem } from "@excalidraw/excalidraw/types";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import type Canvas from "./canvas_el.js";
import { CanvasContent } from "./canvas_el.js";
import { renderReactWidget } from "../react/react_utils.jsx";
import SpacedUpdate from "../../services/spaced_update.js";
import protected_session_holder from "../../services/protected_session_holder.js";
const TPL = /*html*/`
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
<style>
.excalidraw .App-menu_top .buttonList {
display: flex;
}
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
/* https://github.com/zadam/trilium/issues/3780 */
/* https://github.com/excalidraw/excalidraw/issues/6567 */
.excalidraw .dropdown-menu {
display: block;
}
.excalidraw-wrapper {
height: 100%;
}
:root[dir="ltr"]
.excalidraw
.layer-ui__wrapper
.zen-mode-transition.App-menu_bottom--transition-left {
transform: none;
}
/* collaboration not possible so hide the button */
.CollabButton {
display: none !important;
}
.library-button {
display: none !important; /* library won't work without extra support which isn't currently implemented */
}
</style>
<!-- height here necessary. otherwise excalidraw not shown -->
<div class="canvas-render" style="height: 100%"></div>
</div>
`;
interface AttachmentMetadata {
title: string;
attachmentId: string;
}
/**
* # Canvas note with excalidraw
* @author thfrei 2022-05-11
*
* Background:
* excalidraw gives great support for hand-drawn notes. It also allows including images and support
* for sketching. Excalidraw has a vibrant and active community.
*
* Functionality:
* We store the excalidraw assets (elements and files) in the note. In addition to that, we
* export the SVG from the canvas on every update and store it in the note's attachment. It is used when
* calling api/images and makes referencing very easy.
*
* Paths not taken.
* - excalidraw-to-svg (node.js) could be used to avoid storing the svg in the backend.
* We could render the SVG on the fly. However, as of now, it does not render any hand drawn
* (freedraw) paths. There is an issue with Path2D object not present in the node-canvas library
* used by jsdom. (See Trilium PR for samples and other issues in the respective library.
* Link will be added later). Related links:
* - https://github.com/Automattic/node-canvas/pull/2013
* - https://github.com/google/canvas-5-polyfill
* - https://github.com/Automattic/node-canvas/issues/1116
* - https://www.npmjs.com/package/path2d-polyfill
* - excalidraw-to-svg (node.js) takes quite some time to load an image (1-2s)
* - excalidraw-utils (browser) does render freedraw, however NOT freedraw with a background. It is not
* used, since it is a big dependency, and has the same functionality as react + excalidraw.
* - infinite-drawing-canvas with fabric.js. This library lacked a lot of features, excalidraw already
* has.
*
* Known issues:
* - the 3 excalidraw fonts should be included in the share and everywhere, so that it is shown
* when requiring svg.
*
* Discussion of storing svg in the note attachment:
* - Pro: we will combat bit-rot. Showing the SVG will be very fast and easy, since it is already there.
* - Con: The note will get bigger (~40-50%?), we will generate more bandwidth. However, using trilium
* desktop instance mitigates that issue.
*
* Roadmap:
* - Support image-notes as reference in excalidraw
* - Support canvas note as reference (svg) in other canvas notes.
* - Make it easy to include a canvas note inside a text note
*/
export default class ExcalidrawTypeWidget extends TypeWidget {
private currentNoteId: string;
private libraryChanged: boolean;
private librarycache: LibraryItem[];
private attachmentMetadata: AttachmentMetadata[];
private themeStyle!: Theme;
private $render!: JQuery<HTMLElement>;
private reactHandlers!: JQuery<HTMLElement>;
private canvasInstance!: Canvas;
constructor() {
super();
// temporary vars
this.currentNoteId = "";
// will be overwritten
this.$render;
this.$widget;
this.reactHandlers; // used to control react state
this.libraryChanged = false;
// these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted.
//every libraryitem is saved on its own json file in the attachments of the note.
this.librarycache = [];
this.attachmentMetadata = [];
// TODO: We are duplicating the logic of note_detail.ts because it switches note ID mid-save, causing overwrites.
// This problem will get solved by itself once type widgets will be rewritten in React without the use of dangerous singletons.
this.spacedUpdate = new SpacedUpdate(async () => {
if (!this.noteContext) return;
const { note } = this.noteContext;
if (!note) return;
const { noteId } = note;
const data = await this.getData();
// for read only notes
if (data === undefined) return;
protected_session_holder.touchProtectedSessionIfNecessary(note);
await server.put(`notes/${noteId}/data`, data, this.componentId);
this.dataSaved();
});
}
static getType() {
return "canvas";
}
doRender() {
this.$widget = $(TPL);
this.$widget.bind("mousewheel DOMMouseScroll", (event) => {
if (event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
return false;
}
});
this.$widget.toggleClass("full-height", true);
this.$render = this.$widget.find(".canvas-render");
const documentStyle = window.getComputedStyle(document.documentElement);
this.themeStyle = documentStyle.getPropertyValue("--theme-style")?.trim() as Theme;
this.#init();
return this.$widget;
}
async #init() {
const renderElement = this.$render.get(0);
if (!renderElement) {
throw new Error("Unable to find element to render.");
}
const Canvas = (await import("./canvas_el.js")).default;
this.canvasInstance = new Canvas({
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
theme: this.themeStyle,
onChange: () => this.onChangeHandler(),
viewModeEnabled: options.is("databaseReadonly"),
zenModeEnabled: false,
isCollaborating: false,
detectScroll: false,
handleKeyboardGlobally: false,
autoFocus: false,
UIOptions: {
canvasActions: {
saveToActiveFile: false,
export: false
}
},
onLibraryChange: () => {
this.libraryChanged = true;
this.saveData();
},
});
await setupFonts();
const canvasEl = renderReactWidget(this, this.canvasInstance.createCanvasElement())[0];
renderElement.replaceChildren(canvasEl);
}
/**
* called to populate the widget container with the note content
*/
async doRefresh(note: FNote) {
if (!this.canvasInstance) {
await this.#init();
}
// see if the note changed, since we do not get a new class for a new note
const noteChanged = this.currentNoteId !== note.noteId;
if (noteChanged) {
// reset the scene to omit unnecessary onchange handler
this.canvasInstance.resetSceneVersion();
}
this.currentNoteId = note.noteId;
// get note from backend and put into canvas
const blob = await note.getBlob();
// before we load content into excalidraw, make sure excalidraw has loaded
await this.canvasInstance.waitForApiToBecomeAvailable();
/**
* new and empty note - make sure that canvas is empty.
* If we do not set it manually, we occasionally get some "bleeding" from another
* note into this fresh note. Probably due to that this note-instance does not get
* newly instantiated?
*/
if (!blob?.content?.trim()) {
this.canvasInstance.resetScene(this.themeStyle);
} else if (blob.content) {
let content: CanvasContent;
// load saved content into excalidraw canvas
try {
content = blob.getJsonContent() as CanvasContent;
} catch (err) {
console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, blob, err);
content = {
elements: [],
files: [],
appState: {}
};
}
this.canvasInstance.loadData(content, this.themeStyle);
Promise.all(
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
const blob = await attachment.getBlob();
return {
blob, // Save the blob for libraryItems
metadata: {
// metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly
attachmentId: attachment.attachmentId,
title: attachment.title
}
};
})
).then((results) => {
if (note.noteId !== this.currentNoteId) {
// current note changed in the course of the async operation
return;
}
// Extract libraryItems from the blobs
const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[];
// Extract metadata for each attachment
const metadata = results.map((result) => result.metadata);
// Update the library and save to independent variables
this.canvasInstance.updateLibrary(libraryItems);
// save state of library to compare it to the new state later.
this.librarycache = libraryItems;
this.attachmentMetadata = metadata;
});
}
// set initial scene version
if (this.canvasInstance.isInitialScene()) {
this.canvasInstance.updateSceneVersion();
}
}
/**
* gets data from widget container that will be sent via spacedUpdate.scheduleUpdate();
* this is automatically called after this.saveData();
*/
async getData() {
const { content, svg } = await this.canvasInstance.getData();
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
if (this.libraryChanged) {
// this.libraryChanged is unset in dataSaved()
// there's no separate method to get library items, so have to abuse this one
const libraryItems = await this.canvasInstance.getLibraryItems();
// excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note.
//We need the cache to delete old attachments later in the server.
const libraryItemsMissmatch = this.librarycache.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id));
// before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name.
// we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch)
// then we combine its id and title and search the according attachmentID.
const matchingItems = this.attachmentMetadata.filter((meta) => {
// Loop through the second array and check for a match
return libraryItemsMissmatch.some((item) => {
// Combine the `name` and `id` from the second array
const combinedTitle = `${item.id}${item.name}`;
return meta.title === combinedTitle;
});
});
// we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted.
const attachmentIds = matchingItems.map((item) => item.attachmentId);
//delete old attachments that are no longer used
for (const item of attachmentIds) {
await server.remove(`attachments/${item}`);
}
let position = 10;
// prepare data to save to server e.g. new library items.
for (const libraryItem of libraryItems) {
attachments.push({
role: "canvasLibraryItem",
title: libraryItem.id + libraryItem.name,
mime: "application/json",
content: JSON.stringify(libraryItem),
position: position
});
position += 10;
}
}
return {
content: JSON.stringify(content),
attachments
};
}
/**
* save content to backend
*/
saveData() {
// Since Excalidraw sends an enormous amount of events, wait for them to stop before actually saving.
this.spacedUpdate.resetUpdateTimer();
this.spacedUpdate.scheduleUpdate();
}
dataSaved() {
this.libraryChanged = false;
}
onChangeHandler() {
if (options.is("databaseReadonly")) {
return;
}
if (!this.canvasInstance.isInitialized()) return;
// changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc.
// make sure only when a new element is added, we actually save something.
const isNewSceneVersion = this.canvasInstance.isNewSceneVersion();
/**
* FIXME: however, we might want to make an exception, if viewport changed, since viewport
* is desired to save? (add) and appState background, and some things
*/
// upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
const isNotInitialScene = !this.canvasInstance.isInitialScene();
const shouldSave = isNewSceneVersion && isNotInitialScene;
if (shouldSave) {
this.canvasInstance.updateSceneVersion();
this.saveData();
}
}
}
async function setupFonts() {
if (window.EXCALIDRAW_ASSET_PATH) {
return;
}
// currently required by excalidraw, in order to allows self-hosting fonts locally.
// this avoids making excalidraw load the fonts from an external CDN.
let path: string;
if (!glob.isDev) {
path = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
} else {
path = (await import("../../../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2?url")).default;
let pathComponents = path.split("/");
path = pathComponents.slice(0, pathComponents.length - 2).join("/");
}
window.EXCALIDRAW_ASSET_PATH = path;
}

View File

@@ -1,201 +0,0 @@
import "@excalidraw/excalidraw/index.css";
import { Excalidraw, getSceneVersion, exportToSvg } from "@excalidraw/excalidraw";
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
import { ExcalidrawElement, NonDeletedExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
import { useCallback } from "preact/hooks";
import linkService from "../../services/link.js";
export interface CanvasContent {
elements: ExcalidrawElement[];
files: BinaryFileData[];
appState: Partial<AppState>;
}
/** Indicates that it is fresh. excalidraw scene version is always >0 */
const SCENE_VERSION_INITIAL = -1;
export default class Canvas {
private currentSceneVersion: number;
private opts: ExcalidrawProps;
private excalidrawApi!: ExcalidrawImperativeAPI;
private initializedPromise: JQuery.Deferred<void>;
constructor(opts: ExcalidrawProps) {
this.opts = opts;
this.currentSceneVersion = SCENE_VERSION_INITIAL;
this.initializedPromise = $.Deferred();
}
async waitForApiToBecomeAvailable() {
while (!this.excalidrawApi) {
await this.initializedPromise;
}
}
createCanvasElement() {
return <CanvasElement
{...this.opts}
excalidrawAPI={api => {
this.excalidrawApi = api;
this.initializedPromise.resolve();
}}
/>
}
/**
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
* we compare the scene version as suggested in:
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
*
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
*/
isNewSceneVersion() {
const sceneVersion = this.getSceneVersion();
return (
this.currentSceneVersion === SCENE_VERSION_INITIAL || // initial scene version update
this.currentSceneVersion !== sceneVersion
); // ensure scene changed
}
getSceneVersion() {
const elements = this.excalidrawApi.getSceneElements();
return getSceneVersion(elements);
}
updateSceneVersion() {
this.currentSceneVersion = this.getSceneVersion();
}
resetSceneVersion() {
this.currentSceneVersion = SCENE_VERSION_INITIAL;
}
isInitialScene() {
return this.currentSceneVersion === SCENE_VERSION_INITIAL;
}
isInitialized() {
return !!this.excalidrawApi;
}
resetScene(theme: Theme) {
this.excalidrawApi.updateScene({
elements: [],
appState: {
theme
}
});
}
loadData(content: CanvasContent, theme: Theme) {
const { elements, files } = content;
const appState: Partial<AppState> = content.appState ?? {};
appState.theme = theme;
// files are expected in an array when loading. they are stored as a key-index object
// see example for loading here:
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
const fileArray: BinaryFileData[] = [];
for (const fileId in files) {
const file = files[fileId];
// TODO: dataURL is replaceable with a trilium image url
// maybe we can save normal images (pasted) with base64 data url, and trilium images
// with their respective url! nice
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
fileArray.push(file);
}
// Update the scene
// TODO: Fix type of sceneData
this.excalidrawApi.updateScene({
elements,
appState: appState as AppState
});
this.excalidrawApi.addFiles(fileArray);
this.excalidrawApi.history.clear();
}
async getData() {
const elements = this.excalidrawApi.getSceneElements();
const appState = this.excalidrawApi.getAppState();
/**
* A file is not deleted, even though removed from canvas. Therefore, we only keep
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
*/
const files = this.excalidrawApi.getFiles();
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
const svg = await exportToSvg({
elements,
appState,
exportPadding: 5, // 5 px padding
files
});
const svgString = svg.outerHTML;
const activeFiles: Record<string, BinaryFileData> = {};
elements.forEach((element: NonDeletedExcalidrawElement) => {
if ("fileId" in element && element.fileId) {
activeFiles[element.fileId] = files[element.fileId];
}
});
const content = {
type: "excalidraw",
version: 2,
elements,
files: activeFiles,
appState: {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
gridModeEnabled: appState.gridModeEnabled
}
};
return {
content,
svg: svgString
}
}
async getLibraryItems() {
return this.excalidrawApi.updateLibrary({
libraryItems() {
return [];
},
merge: true
});
}
async updateLibrary(libraryItems: LibraryItem[]) {
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
}
}
function CanvasElement(opts: ExcalidrawProps) {
return (
<div className="excalidraw-wrapper">
<Excalidraw
{...opts}
onLinkOpen={useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
let link = element.link;
if (!link) {
return false;
}
if (link.startsWith("root/")) {
link = "#" + link;
}
const { nativeEvent } = event.detail;
event.preventDefault();
return linkService.goToLinkExt(nativeEvent, link, null);
}, [])}
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from "preact/hooks";
import "./code.css";
import { CodeEditor } from "./Code";
import CodeMirror from "@triliumnext/codemirror";
import server from "../../../services/server";
import { useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
export default function BackendLog({ ntxId, parentComponent }: TypeWidgetProps) {
const [ content, setContent ] = useState<string>();
const editorRef = useRef<CodeMirror>(null);
function refresh() {
server.get<string>("backend-log").then(content => {
setContent(content);
});
}
useEffect(refresh, []);
// Scroll to end
useEffect(() => {
requestAnimationFrame(() => editorRef.current?.scrollToEnd());
}, [ content ]);
// React to refresh button.
useTriliumEvent("refreshData", ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
refresh();
});
return (
<div className="backend-log-editor-container">
<CodeEditor
editorRef={editorRef}
ntxId={ntxId} parentComponent={parentComponent}
content={content ?? ""}
mime="text/plain"
readOnly
preferPerformance
/>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { getThemeById, default as VanillaCodeMirror } from "@triliumnext/codemirror";
import { TypeWidgetProps } from "../type_widget";
import "./code.css";
import CodeMirror, { CodeMirrorProps } from "./CodeMirror";
import utils from "../../../services/utils";
import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { t } from "../../../services/i18n";
import appContext, { CommandListenerData } from "../../../components/app_context";
import TouchBar, { TouchBarButton } from "../../react/TouchBar";
import { refToJQuerySelector } from "../../react/react_utils";
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
import FNote from "../../../entities/fnote";
interface CodeEditorProps {
/** By default, the code editor will try to match the color of the scrolling container to match the one from the theme for a full-screen experience. If the editor is embedded, it makes sense not to have this behaviour. */
noBackgroundChange?: boolean;
}
export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps, "onContentChanged"> {
// if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
debounceUpdate?: boolean;
lineWrapping?: boolean;
updateInterval?: number;
/** Invoked when the content of the note is changed, such as a different revision or a note switch. */
onContentChanged?: (content: string) => void;
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
dataSaved?: () => void;
}
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
const [ content, setContent ] = useState("");
const blob = useNoteBlob(note);
useEffect(() => {
if (!blob) return;
let newContent = blob.content;
if (viewScope?.viewMode === "source") {
newContent = formatViewSource(note, newContent);
}
setContent(newContent);
}, [ blob ]);
return (
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
className="note-detail-readonly-code-content"
content={content}
mime={note.mime}
readOnly
/>
)
}
function formatViewSource(note: FNote, content: string) {
if (note.type === "text") {
return utils.formatHtml(content);
}
if (note.type !== "code" && note.mime === "application/json" && content.length < 512_000) {
try {
return JSON.stringify(JSON.parse(content), null, 4);
} catch (e) {
// Fallback to content.
}
}
return content;
}
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, onContentChanged, dataSaved, ...editorProps }: EditableCodeProps) {
const editorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useRef<HTMLPreElement>(null);
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: () => ({ content: editorRef.current?.getText() }),
onContentChange: (content) => {
const codeEditor = editorRef.current;
if (!codeEditor) return;
codeEditor.setText(content ?? "");
codeEditor.setMimeType(note.mime);
codeEditor.clearHistory();
},
dataSaved,
updateInterval
});
// make sure that script is saved before running it #4028
useLegacyImperativeHandlers({
async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
if (params.ntxId === ntxId) {
await spacedUpdate.updateNowIfNecessary();
}
return await parentComponent?.parent?.triggerCommand("runActiveNote", params);
}
});
useKeyboardShortcuts("code-detail", containerRef, parentComponent);
return (
<>
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
editorRef={editorRef} containerRef={containerRef}
mime={note.mime}
className="note-detail-code-editor"
placeholder={t("editable_code.placeholder")}
vimKeybindings={vimKeymapEnabled}
tabIndex={300}
onContentChanged={() => {
if (debounceUpdate) {
spacedUpdate.resetUpdateTimer();
}
spacedUpdate.scheduleUpdate();
if (editorRef.current && onContentChanged) {
onContentChanged(editorRef.current.getText());
}
}}
{...editorProps}
/>
<TouchBar>
{(note?.mime.startsWith("application/javascript") || note?.mime === "text/x-sqlite;schema=trilium") && (
<TouchBarButton icon="NSImageNameTouchBarPlayTemplate" click={() => appContext.triggerCommand("runActiveNote")} />
)}
</TouchBar>
</>
)
}
export function CodeEditor({ parentComponent, ntxId, containerRef: externalContainerRef, editorRef: externalEditorRef, mime, onInitialized, lineWrapping, noBackgroundChange, ...editorProps }: CodeEditorProps & CodeMirrorProps & Pick<TypeWidgetProps, "parentComponent" | "ntxId">) {
const codeEditorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useSyncedRef(externalContainerRef);
const initialized = useRef($.Deferred());
const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme");
// React to background color.
const [ backgroundColor, setBackgroundColor ] = useState<string>();
useEffect(() => {
if (!backgroundColor || noBackgroundChange) return;
parentComponent?.$widget.closest(".scrolling-container").css("background-color", backgroundColor);
return () => {
parentComponent?.$widget.closest(".scrolling-container").css("background-color", "unset");
};
}, [ backgroundColor ]);
// React to theme changes.
useEffect(() => {
if (codeEditorRef.current && codeNoteTheme.startsWith(DEFAULT_PREFIX)) {
const theme = getThemeById(codeNoteTheme.substring(DEFAULT_PREFIX.length));
if (theme) {
codeEditorRef.current.setTheme(theme).then(() => {
if (mime === "text/x-sqlite;schema=trilium") return;
const editor = containerRef.current?.querySelector(".cm-editor");
if (!editor) return;
const style = window.getComputedStyle(editor);
setBackgroundColor(style.backgroundColor);
});
}
}
}, [ codeEditorRef, codeNoteTheme ]);
useTriliumEvent("executeWithCodeEditor", async ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
await initialized.current.promise();
resolve(codeEditorRef.current!);
});
useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => {
if (eventNtxId !== ntxId) return;
await initialized.current.promise();
resolve(refToJQuerySelector(containerRef));
});
useTriliumEvent("scrollToEnd", () => {
const editor = codeEditorRef.current;
if (!editor) return;
editor.scrollToEnd();
editor.focus();
});
useTriliumEvent("focusOnDetail", ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
codeEditorRef.current?.focus();
});
return <CodeMirror
{...editorProps}
mime={mime}
editorRef={codeEditorRef}
containerRef={containerRef}
lineWrapping={lineWrapping ?? codeLineWrapEnabled}
onInitialized={() => {
if (externalContainerRef && containerRef.current) {
externalContainerRef.current = containerRef.current;
}
if (externalEditorRef && codeEditorRef.current) {
externalEditorRef.current = codeEditorRef.current;
}
initialized.current.resolve();
onInitialized?.();
}}
/>
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from "preact/hooks";
import { EditorConfig, default as VanillaCodeMirror } from "@triliumnext/codemirror";
import { useSyncedRef } from "../../react/hooks";
import { RefObject } from "preact";
export interface CodeMirrorProps extends Omit<EditorConfig, "parent"> {
content?: string;
mime: string;
className?: string;
editorRef?: RefObject<VanillaCodeMirror>;
containerRef?: RefObject<HTMLPreElement>;
onInitialized?: () => void;
}
export default function CodeMirror({ className, content, mime, editorRef: externalEditorRef, containerRef: externalContainerRef, onInitialized, lineWrapping, ...extraOpts }: CodeMirrorProps) {
const parentRef = useSyncedRef(externalContainerRef);
const codeEditorRef = useRef<VanillaCodeMirror>();
// Create CodeMirror instance.
useEffect(() => {
if (!parentRef.current) return;
const codeEditor = new VanillaCodeMirror({
parent: parentRef.current,
...extraOpts
});
codeEditorRef.current = codeEditor;
if (externalEditorRef) {
externalEditorRef.current = codeEditor;
}
onInitialized?.();
return () => codeEditor.destroy();
}, []);
// React to text changes.
useEffect(() => {
const codeEditor = codeEditorRef.current;
codeEditor?.setText(content ?? "");
codeEditor?.setMimeType(mime);
codeEditor?.clearHistory();
}, [content]);
// React to line wrapping.
useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]);
return (
<pre ref={parentRef} className={className} />
)
}

View File

@@ -0,0 +1,34 @@
/* #region Read-only code */
.note-detail-readonly-code {
min-height: 50px;
position: relative;
}
/* #endregion */
/* #region Editable code */
.note-detail-code {
position: relative;
height: 100%;
}
.note-detail-code-editor {
min-height: 50px;
height: 100%;
}
/* #endregion */
/* #region Backend log */
.backend-log-editor-container {
height: 100%;
display: flex;
flex-direction: column;
}
.backend-log-editor {
flex-grow: 1;
width: 100%;
border: none;
resize: none;
margin-bottom: 0;
}
/* #endregion */

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