mirror of
https://github.com/zadam/trilium.git
synced 2025-10-27 16:26:31 +01:00
Compare commits
376 Commits
fix/fix-eq
...
react/type
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8828e36624 | ||
|
|
d8e9cad23d | ||
|
|
6ed333d222 | ||
|
|
d534db29c9 | ||
|
|
40edd42740 | ||
|
|
d2c7011735 | ||
|
|
a050d1741b | ||
|
|
18982865da | ||
|
|
3aa810fed7 | ||
|
|
c5ecc22c67 | ||
|
|
252f8ccb1f | ||
|
|
e1bb704383 | ||
|
|
dce0d9400b | ||
|
|
615c783fe3 | ||
|
|
f29411baf7 | ||
|
|
be5e70130c | ||
|
|
9ba1e9d732 | ||
|
|
e1dc4d1433 | ||
|
|
d0d268496c | ||
|
|
8a6950c945 | ||
|
|
477592d176 | ||
|
|
7e5c2ed79d | ||
|
|
bc580f2a88 | ||
|
|
71cd92e0b5 | ||
|
|
a4d92e12be | ||
|
|
c40279b480 | ||
|
|
4c7e7c157c | ||
|
|
c08386450a | ||
|
|
eb93762ecc | ||
|
|
2697f9a25d | ||
|
|
9515e2099b | ||
|
|
966c08da87 | ||
|
|
ea04446e81 | ||
|
|
e4f806ed14 | ||
|
|
49cf7ae1a3 | ||
|
|
1a6f5a027f | ||
|
|
f4796f0f9e | ||
|
|
30480b2c23 | ||
|
|
b7b1d17817 | ||
|
|
c4e5494c14 | ||
|
|
b0f63c02c9 | ||
|
|
2480509811 | ||
|
|
7872193ed0 | ||
|
|
14e06c4555 | ||
|
|
b8e17959ae | ||
|
|
c16a135efc | ||
|
|
cbc756ba06 | ||
|
|
64daeb0826 | ||
|
|
e15839db47 | ||
|
|
dcdffed003 | ||
|
|
48e85fad43 | ||
|
|
189071deb8 | ||
|
|
354f1d65c1 | ||
|
|
b78893b106 | ||
|
|
9310315c6a | ||
|
|
1794f8546d | ||
|
|
b3bc0572e5 | ||
|
|
253ce1f223 | ||
|
|
2f3bf94b47 | ||
|
|
d802caa03b | ||
|
|
e69751a8b3 | ||
|
|
0760ea22fb | ||
|
|
8a8f407e99 | ||
|
|
e030dd96da | ||
|
|
01abfc2528 | ||
|
|
042b929dc5 | ||
|
|
ab1d5e31fb | ||
|
|
d073e4c37f | ||
|
|
d60d965a42 | ||
|
|
1c87cfbbd9 | ||
|
|
fee333512a | ||
|
|
38a3f46506 | ||
|
|
bf7506fcd8 | ||
|
|
6fbba426de | ||
|
|
d5bdec13b5 | ||
|
|
cc1b6eb42d | ||
|
|
8baf496f96 | ||
|
|
23a20c4490 | ||
|
|
c8b98f2db6 | ||
|
|
3f36f515db | ||
|
|
892eb5b95d | ||
|
|
62a69a0da0 | ||
|
|
3588e38543 | ||
|
|
41450ab85a | ||
|
|
0526d99560 | ||
|
|
557d576b85 | ||
|
|
041c961cfa | ||
|
|
dcc35bd507 | ||
|
|
09c3e5b56e | ||
|
|
950793377d | ||
|
|
7dac61dc26 | ||
|
|
42dcb8f141 | ||
|
|
43dc8a4b87 | ||
|
|
35316a4c45 | ||
|
|
1366489f99 | ||
|
|
31ee78b1aa | ||
|
|
808ba75ee0 | ||
|
|
ac1399a139 | ||
|
|
1e4793351a | ||
|
|
f502fe41c7 | ||
|
|
0ec0091357 | ||
|
|
0e2196f872 | ||
|
|
32dee254cd | ||
|
|
d4a6a297f4 | ||
|
|
a64d8cd8e2 | ||
|
|
bf4cfb9c02 | ||
|
|
a99dfecf43 | ||
|
|
1530d96eca | ||
|
|
5dc066f4c6 | ||
|
|
1e8f179f81 | ||
|
|
54c906de8d | ||
|
|
114b3ef4d1 | ||
|
|
f6fa1e69b3 | ||
|
|
fcc8086f9c | ||
|
|
7eefff0a74 | ||
|
|
1b842e35ff | ||
|
|
c9021ca742 | ||
|
|
b229ab3c02 | ||
|
|
6825f28ba0 | ||
|
|
5e72f271ea | ||
|
|
9ad6dfd5e9 | ||
|
|
81031673c3 | ||
|
|
1a6423fd36 | ||
|
|
9b872617e6 | ||
|
|
57be2e2474 | ||
|
|
1d65afef53 | ||
|
|
b6385618d1 | ||
|
|
e3d7c7419f | ||
|
|
2a6c295967 | ||
|
|
f5f32df847 | ||
|
|
1f350b2730 | ||
|
|
386992255e | ||
|
|
eb505c4615 | ||
|
|
003d2b5354 | ||
|
|
b452f78242 | ||
|
|
7d1abee8e4 | ||
|
|
d503993a74 | ||
|
|
fe98ba8c8c | ||
|
|
18608ecb34 | ||
|
|
ab6da26a25 | ||
|
|
f95082ccdb | ||
|
|
e94b5ac07a | ||
|
|
5d0669b464 | ||
|
|
af95d85b73 | ||
|
|
aae90ede19 | ||
|
|
0fa1c0f5c4 | ||
|
|
d2b6014b49 | ||
|
|
94d62f810a | ||
|
|
e953f0cc1a | ||
|
|
347da8abde | ||
|
|
5ff07820d3 | ||
|
|
6eccaac4bb | ||
|
|
f5038a08e5 | ||
|
|
d1e6bd9c3a | ||
|
|
7911973a83 | ||
|
|
968b595aec | ||
|
|
704f2c2238 | ||
|
|
f6b86d725c | ||
|
|
a7e0866e0d | ||
|
|
2cb3b877d1 | ||
|
|
0b808b8db3 | ||
|
|
f02af893bb | ||
|
|
cb3f941760 | ||
|
|
93f145a20f | ||
|
|
d34e2a0246 | ||
|
|
9a0b4f67ed | ||
|
|
651e158e3a | ||
|
|
d11784a894 | ||
|
|
14db789b7f | ||
|
|
c4a4995da0 | ||
|
|
45de9da893 | ||
|
|
a6fce1b4c8 | ||
|
|
1d28a5e5b8 | ||
|
|
d4b05fa0a0 | ||
|
|
4bcf209072 | ||
|
|
4b34ae3fd4 | ||
|
|
8590ff1f46 | ||
|
|
c4e2c003de | ||
|
|
bca0846565 | ||
|
|
8fef28dcc7 | ||
|
|
040ffe945a | ||
|
|
4997543fc7 | ||
|
|
63c91b6741 | ||
|
|
27b6e26fa5 | ||
|
|
7930745a01 | ||
|
|
6ffe8a2eb5 | ||
|
|
0dcaa8719f | ||
|
|
608605af12 | ||
|
|
3f7b8447d0 | ||
|
|
d3594e4a05 | ||
|
|
156b4101a5 | ||
|
|
73213d2a17 | ||
|
|
763bcbd394 | ||
|
|
d90043e586 | ||
|
|
c209a699ea | ||
|
|
22069d0aef | ||
|
|
3248654820 | ||
|
|
269c7c9ce7 | ||
|
|
b0c984decd | ||
|
|
cebb54ddf6 | ||
|
|
22f8929da6 | ||
|
|
7192d40e80 | ||
|
|
df9d481a93 | ||
|
|
cf37549f19 | ||
|
|
d2dda95654 | ||
|
|
0770f97010 | ||
|
|
3caaf2ab79 | ||
|
|
8f819a7786 | ||
|
|
0da66617a8 | ||
|
|
5efe05490d | ||
|
|
656b234740 | ||
|
|
e6e9cd3f35 | ||
|
|
845c76fc42 | ||
|
|
a4d6da72a1 | ||
|
|
35438d2599 | ||
|
|
9a1e7ca3ae | ||
|
|
2d29d1b41f | ||
|
|
ad5ff6e41a | ||
|
|
20dcbff68f | ||
|
|
c127e19cfa | ||
|
|
e32237559e | ||
|
|
09811d23f6 | ||
|
|
b41042fec4 | ||
|
|
08fae19d19 | ||
|
|
9cceff4f02 | ||
|
|
67d9154795 | ||
|
|
1eca9f6541 | ||
|
|
c469fffb6e | ||
|
|
d076d54170 | ||
|
|
3256c14a20 | ||
|
|
460e01a2d6 | ||
|
|
1913355069 | ||
|
|
f687d91201 | ||
|
|
e8e93e985d | ||
|
|
c5c304f85b | ||
|
|
58aea03114 | ||
|
|
4af842d2f2 | ||
|
|
3b2f5bb09d | ||
|
|
2d67aab288 | ||
|
|
838d761b50 | ||
|
|
7a2d91e7de | ||
|
|
082ea7b5c1 | ||
|
|
c58414bbc1 | ||
|
|
1c1243912b | ||
|
|
614fc66890 | ||
|
|
0937ef72e2 | ||
|
|
3571023685 | ||
|
|
2cd3e3f9c8 | ||
|
|
3d08f686cf | ||
|
|
d2bf972305 | ||
|
|
39bd236799 | ||
|
|
d8b9d14712 | ||
|
|
9d4127ba6d | ||
|
|
04b678ef4c | ||
|
|
286d7c8228 | ||
|
|
5547c3fc2b | ||
|
|
4381399978 | ||
|
|
5bfa0d13e3 | ||
|
|
5c21759de9 | ||
|
|
e2ef58ed50 | ||
|
|
7af610a5b4 | ||
|
|
8a442ba492 | ||
|
|
3ed399a888 | ||
|
|
37d33fb975 | ||
|
|
d443d79685 | ||
|
|
a975576214 | ||
|
|
3673162a48 | ||
|
|
0ac428b57a | ||
|
|
45bd9b72b9 | ||
|
|
cc6ac7d1da | ||
|
|
232fe4e63a | ||
|
|
597426f10d | ||
|
|
a0a904766f | ||
|
|
db46ca0a76 | ||
|
|
a26ee0d769 | ||
|
|
46db047fa0 | ||
|
|
efaa1815ec | ||
|
|
2eab8b92d5 | ||
|
|
8a185262fb | ||
|
|
f6631b7b9a | ||
|
|
1e323de01b | ||
|
|
f00f2ee5e4 | ||
|
|
78b83cd17b | ||
|
|
adea3abff4 | ||
|
|
206618fd54 | ||
|
|
58a6d70cbb | ||
|
|
44b92a024c | ||
|
|
68bf5b7e68 | ||
|
|
8c85aa343c | ||
|
|
592a8b2232 | ||
|
|
e1ac319a7b | ||
|
|
763c489cd3 | ||
|
|
b990770e48 | ||
|
|
344607d437 | ||
|
|
70d0a5441a | ||
|
|
61278e1f5a | ||
|
|
b73ea6ac4f | ||
|
|
5d833c1ac4 | ||
|
|
2947682783 | ||
|
|
fb46e09428 | ||
|
|
ff941b2cb1 | ||
|
|
a8007b9063 | ||
|
|
2f3c2bbac8 | ||
|
|
e4eb96a1ae | ||
|
|
ffe4e9b8de | ||
|
|
f2b4f49be2 | ||
|
|
376ef0c679 | ||
|
|
b7574b3ca7 | ||
|
|
ae1954c320 | ||
|
|
3171413a18 | ||
|
|
dc73467d34 | ||
|
|
58b14ae31c | ||
|
|
e117fbd471 | ||
|
|
9a3f675950 | ||
|
|
26400f2590 | ||
|
|
7d99a92bd9 | ||
|
|
3417e37f16 | ||
|
|
143e6a556c | ||
|
|
02259c55f3 | ||
|
|
cc19a217ad | ||
|
|
d95ed4a5d2 | ||
|
|
469683f30f | ||
|
|
42d0cc12b5 | ||
|
|
b376842e2d | ||
|
|
145ff1a2a5 | ||
|
|
8e9f5fb486 | ||
|
|
3dd757a857 | ||
|
|
bde7b753a0 | ||
|
|
02017ebd9d | ||
|
|
8caaa99415 | ||
|
|
c49b90d33f | ||
|
|
6dd939df14 | ||
|
|
b19da81572 | ||
|
|
425ffc02d8 | ||
|
|
695e8489ad | ||
|
|
2f4e13b1bb | ||
|
|
c8a9b994d6 | ||
|
|
3d5b319eb2 | ||
|
|
bed3c2dc67 | ||
|
|
256d1863d2 | ||
|
|
4a4502dfea | ||
|
|
91f21e149b | ||
|
|
6ef468adc4 | ||
|
|
e576fa03da | ||
|
|
6bcce08042 | ||
|
|
f496caa92c | ||
|
|
43dcdf8925 | ||
|
|
2c014fb071 | ||
|
|
2273507ef4 | ||
|
|
70a710be79 | ||
|
|
7a3ee7971c | ||
|
|
c86123e3a9 | ||
|
|
9480227b69 | ||
|
|
79be13e6c7 | ||
|
|
63e3a27b34 | ||
|
|
9eae6620d0 | ||
|
|
6517dd1190 | ||
|
|
f72087acc3 | ||
|
|
77e7c414b6 | ||
|
|
3a68395ca7 | ||
|
|
0a0d9775b2 | ||
|
|
aa6e68ad39 | ||
|
|
034073a5e1 | ||
|
|
d83ff641d7 | ||
|
|
071fcb85c9 | ||
|
|
daa5ee93e9 | ||
|
|
db7cda3fe6 | ||
|
|
fa55c5720e | ||
|
|
d1a9890932 | ||
|
|
c9fe358811 | ||
|
|
bbb927c83f | ||
|
|
07b86c8cf7 | ||
|
|
3dbf20af52 | ||
|
|
1fb329565f | ||
|
|
06bfb0073a | ||
|
|
3d64c320fb |
8
.github/workflows/main-docker.yml
vendored
8
.github/workflows/main-docker.yml
vendored
@@ -86,12 +86,12 @@ jobs:
|
||||
|
||||
- name: Upload Playwright trace
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: Playwright trace (${{ matrix.dockerfile }})
|
||||
path: test-output/playwright/output
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: Playwright report (${{ matrix.dockerfile }})
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
||||
path: /tmp/digests/*
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
name: Nightly Build
|
||||
|
||||
- name: Publish artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
with:
|
||||
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
||||
|
||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: e2e report
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
||||
path: apps/desktop/upload/*.*
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: upload/*.*
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@stylistic/eslint-plugin": "5.5.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express": "5.0.4",
|
||||
"@types/node": "22.18.12",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/yargs": "17.0.34",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.99.2",
|
||||
"version": "0.99.3",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -55,11 +55,11 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.4.1",
|
||||
"mermaid": "11.12.0",
|
||||
"mind-elixir": "5.3.3",
|
||||
"mind-elixir": "5.3.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.2",
|
||||
"react-i18next": "16.1.2",
|
||||
"react-i18next": "16.2.0",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
@@ -74,9 +74,9 @@
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.1",
|
||||
"@types/tabulator-tables": "6.2.11",
|
||||
"@types/tabulator-tables": "6.3.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.0.7",
|
||||
"happy-dom": "20.0.8",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
@@ -397,7 +398,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
|
||||
|
||||
@@ -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 media="screen" />)
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
|
||||
@@ -26,11 +26,12 @@ 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 FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@@ -63,9 +64,9 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />))
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
|
||||
@@ -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,10 @@ 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";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -153,8 +156,10 @@ export default class MobileLayout {
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
||||
.child(<SearchResult />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -280,7 +280,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
|
||||
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -169,7 +169,7 @@ const entityMap: Record<string, string> = {
|
||||
"=": "="
|
||||
};
|
||||
|
||||
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.
|
||||
|
||||
@@ -407,7 +407,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;
|
||||
@@ -2432,4 +2432,8 @@ iframe.print-iframe {
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.excalidraw.theme--dark canvas {
|
||||
--theme-filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
@@ -86,6 +86,13 @@ body ::-webkit-calendar-picker-indicator {
|
||||
--custom-color: var(--dark-theme-custom-color);
|
||||
}
|
||||
|
||||
:root .reference-link,
|
||||
:root .reference-link:hover,
|
||||
.ck-content a.reference-link > span,
|
||||
.board-note {
|
||||
color: var(--dark-theme-custom-color, inherit);
|
||||
}
|
||||
|
||||
.excalidraw.theme--dark {
|
||||
--theme-filter: invert(80%) hue-rotate(180deg) !important;
|
||||
}
|
||||
@@ -101,3 +108,4 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
.ck-content pre {
|
||||
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,4 +84,11 @@ html {
|
||||
|
||||
#left-pane .fancytree-node.tinted {
|
||||
--custom-color: var(--light-theme-custom-color);
|
||||
}
|
||||
|
||||
:root .reference-link,
|
||||
:root .reference-link:hover,
|
||||
.ck-content a.reference-link > span,
|
||||
.board-note {
|
||||
color: var(--light-theme-custom-color, inherit);
|
||||
}
|
||||
@@ -277,6 +277,13 @@
|
||||
--custom-bg-color: hsl(var(--custom-color-hue), 20%, 33%, 0.4);
|
||||
}
|
||||
|
||||
:root .reference-link,
|
||||
:root .reference-link:hover,
|
||||
.ck-content a.reference-link > span,
|
||||
.board-note {
|
||||
color: var(--dark-theme-custom-color, inherit);
|
||||
}
|
||||
|
||||
body ::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@@ -575,9 +575,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 {
|
||||
|
||||
@@ -392,7 +392,8 @@ div.tn-tool-dialog {
|
||||
}
|
||||
|
||||
.delete-notes-list .note-path {
|
||||
padding-inline-end: 8px;
|
||||
padding-inline-start: 8px;
|
||||
color: var(--muted-text-color)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -666,4 +666,17 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
|
||||
.ck-content .table > figcaption {
|
||||
background: var(--accented-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
/* Reference link */
|
||||
|
||||
.ck-content a.reference-link,
|
||||
.ck-content a.reference-link:hover {
|
||||
/* Apply underline only to the span inside the link so it can follow the
|
||||
* target note's user defined color */
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ck-content a.reference-link > span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
/* Button bar */
|
||||
.search-definition-widget .search-setting-table tbody:last-child div {
|
||||
justify-content: flex-end !important;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "خطأ فادح"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "فشل في البدء بعنصر الواجهة"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@@ -26,7 +29,8 @@
|
||||
"edit_branch_prefix": "تعديل بادئة الفرع",
|
||||
"prefix": "البادئة: ",
|
||||
"save": "حفظ",
|
||||
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة"
|
||||
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة",
|
||||
"branch_prefix_saved": "تم حفظ بادئة الفرع."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "اجراءات جماعية",
|
||||
@@ -83,7 +87,8 @@
|
||||
"workspace_calendar_root": "تحديد جذر التقويم لكل مساحة عمل",
|
||||
"hide_highlight_widget": "اخفاء عنصر واجهة قائمة التمييزات",
|
||||
"is_owned_by_note": "تخص الملاحظة",
|
||||
"and_more": "... و {{count}}مرات اكثر."
|
||||
"and_more": "... و {{count}}مرات اكثر.",
|
||||
"related_notes_title": "ملاحظات اخرى بنفس التسمية"
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "الى",
|
||||
@@ -127,7 +132,9 @@
|
||||
"delete_attachment": "حذف المرفق",
|
||||
"upload_new_revision": "رفع مراجعة جديدة",
|
||||
"copy_link_to_clipboard": "نسخ الرابط الى الحافظة",
|
||||
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة"
|
||||
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة",
|
||||
"delete_success": "تم حذف المرفق \"{{title}}\" .",
|
||||
"enter_new_name": "ادخل اسم مرفق جديد"
|
||||
},
|
||||
"calendar": {
|
||||
"week": "أسبوع",
|
||||
@@ -259,7 +266,8 @@
|
||||
"note_paths": {
|
||||
"search": "بحث",
|
||||
"archived": "مؤرشف",
|
||||
"title": "مسارات الملاحظة"
|
||||
"title": "مسارات الملاحظة",
|
||||
"clone_button": "جار نسخ الملاحظة الى مكان جديد..."
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "استعلام",
|
||||
@@ -372,7 +380,8 @@
|
||||
"export_note_title": "تصدير الملاحظة",
|
||||
"export_status": "حالة التصدير",
|
||||
"export_finished_successfully": "اكتمل التصدير بنجاح.",
|
||||
"export_in_progress": "جار التصدير: {{progressCount}}"
|
||||
"export_in_progress": "جار التصدير: {{progressCount}}",
|
||||
"choose_export_type": "اختر نوع التصدير اولا من فضلك"
|
||||
},
|
||||
"help": {
|
||||
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
|
||||
@@ -402,7 +411,10 @@
|
||||
"movingCloningNotes": "نقل/ استنساخ الملاحظات",
|
||||
"deleteNotes": "حذف الملاحظة/ الشجرة الفرعية",
|
||||
"collapseWholeTree": "طي شجرة الملاحظة باكملها",
|
||||
"followLink": "اتبع تلرابط تحت المؤشر"
|
||||
"followLink": "اتبع تلرابط تحت المؤشر",
|
||||
"onlyInDesktop": "في سطح المكتب فقط(Electron build)",
|
||||
"createEditLink": "انشاء/ تحرير رابط خارجي",
|
||||
"quickSearch": "الانتقال الى مربع البحث السريع"
|
||||
},
|
||||
"import": {
|
||||
"options": "خيارات",
|
||||
@@ -465,7 +477,13 @@
|
||||
"delete_all_button": "حذف كل المراجعات",
|
||||
"settings": "اعدادات مراجعة الملاحظة",
|
||||
"diff_not_available": "المقارنة غير متوفرة.",
|
||||
"help_title": "مساعدة حول مراجعات الملاحظة"
|
||||
"help_title": "مساعدة حول مراجعات الملاحظة",
|
||||
"diff_off_hint": "انقر لعرض محتويات الملاحظة",
|
||||
"revisions_deleted": "تم حذف جميع نسخ المراجعات للملاحظة.",
|
||||
"revision_restored": "تم استعادة نسخ المراجعة للملاحظة.",
|
||||
"revision_deleted": "تم حذف مراجعة الملاحظة.",
|
||||
"snapshot_interval": "فاصل زمني لحفظ لقطات اصدارات المراجعة: {{seconds}}",
|
||||
"maximum_revisions": "حد عدد لقطات اصدارات الملاحظة: {{number}}"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"title": "عنوان",
|
||||
@@ -479,13 +497,15 @@
|
||||
"sorting_direction": "اتجاه الترتيب",
|
||||
"natural_sort": "الترتيب الطبيعي",
|
||||
"natural_sort_language": "لغات الترتيب الطبيعي",
|
||||
"sort_children_by": "ترتيب العناصر الفرعية حسب..."
|
||||
"sort_children_by": "ترتيب العناصر الفرعية حسب...",
|
||||
"sort_folders_at_top": "ترتيب المجلدات في الاعلى"
|
||||
},
|
||||
"recent_changes": {
|
||||
"undelete_link": "الغاء الحذف",
|
||||
"title": "التغيرات الاخيرة",
|
||||
"no_changes_message": "لايوجد تغيير لحد الان...",
|
||||
"erase_notes_button": "مسح الملاحظات المحذوفة الان"
|
||||
"erase_notes_button": "مسح الملاحظات المحذوفة الان",
|
||||
"deleted_notes_message": "تم حذف الملاحظات نهائيا."
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(حذف)",
|
||||
@@ -655,7 +675,11 @@
|
||||
"google": "جوجل",
|
||||
"save_button": "حفظ",
|
||||
"baidu": "Baidu",
|
||||
"title": "محرك البحث"
|
||||
"title": "محرك البحث",
|
||||
"predefined_templates_label": "قوالب محرك البحث المعرفة مسبقا",
|
||||
"custom_name_label": "اسم محرك البحث المخصص",
|
||||
"custom_name_placeholder": "اسم محرك البحث المخصص",
|
||||
"custom_url_placeholder": "تخصيص عنوان URL لمحرك البحث"
|
||||
},
|
||||
"heading_style": {
|
||||
"plain": "بسيط",
|
||||
@@ -676,7 +700,8 @@
|
||||
"wednesday": "الاربعاء",
|
||||
"thursday": "الخميس",
|
||||
"friday": "الجمعة",
|
||||
"saturday": "السبت"
|
||||
"saturday": "السبت",
|
||||
"formatting-locale": "تنسيق التاريخ والارقام"
|
||||
},
|
||||
"backup": {
|
||||
"path": "مسار",
|
||||
@@ -699,7 +724,10 @@
|
||||
"token_name": "اسم الرمز",
|
||||
"default_token_name": "رمز جديد",
|
||||
"rename_token_title": "اعادة تسمية الرمز",
|
||||
"rename_token": "اعادة تسمية هذا الرمز"
|
||||
"rename_token": "اعادة تسمية هذا الرمز",
|
||||
"create_token": "انشاء رمز PEAPI جديد",
|
||||
"new_token_title": "رمز ETAPI جديد",
|
||||
"token_created_title": "انشاء رمز ETAPI"
|
||||
},
|
||||
"password": {
|
||||
"heading": "كلمة المرور",
|
||||
@@ -731,7 +759,8 @@
|
||||
"timeout": "انتهاء مهلة المزامنة",
|
||||
"test_title": "اختبار المزامنة",
|
||||
"test_button": "اختبار المزامنة",
|
||||
"server_address": "عنوان نسخة الخادم"
|
||||
"server_address": "عنوان نسخة الخادم",
|
||||
"proxy_label": "خادم وكيل المزامنة (اخياري)"
|
||||
},
|
||||
"api_log": {
|
||||
"close": "أغلاق"
|
||||
@@ -751,7 +780,8 @@
|
||||
"new_tab": "تبويب جديد",
|
||||
"close_all_tabs": "اغلاق كل علامات التبويب",
|
||||
"add_new_tab": "اضافة علامة تبويب جديدة",
|
||||
"close_other_tabs": "اغلاق علامات التبويب الاخرى"
|
||||
"close_other_tabs": "اغلاق علامات التبويب الاخرى",
|
||||
"reopen_last_tab": "اعادة فتح اخر علامة تبويب مغلقة"
|
||||
},
|
||||
"toc": {
|
||||
"options": "خيارات",
|
||||
@@ -791,7 +821,8 @@
|
||||
},
|
||||
"call_to_action": {
|
||||
"dismiss": "تجاهل",
|
||||
"background_effects_button": "تفعيل مؤثرات الخلفية"
|
||||
"background_effects_button": "تفعيل مؤثرات الخلفية",
|
||||
"next_theme_button": "جرب النسق الجديد"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
@@ -802,7 +833,8 @@
|
||||
"help_on_links": "مساعدة حول الارتباطات التشعبية",
|
||||
"notes_to_clone": "ملاحظات للنسخ",
|
||||
"target_parent_note": "الملاحظة الاصلية الهدف",
|
||||
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة"
|
||||
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة",
|
||||
"no_path_to_clone_to": "لايوجد مسار لنسخ المحتوى الية."
|
||||
},
|
||||
"table_of_contents": {
|
||||
"unit": "عناوين",
|
||||
@@ -835,7 +867,8 @@
|
||||
"search-in-subtree": "البحث في الشجرة الفرعية",
|
||||
"edit-branch-prefix": "تعديل بادئة الفرع",
|
||||
"convert-to-attachment": "التحويل الى مرفق",
|
||||
"apply-bulk-actions": "تطبيق الاجراءات الجماعية"
|
||||
"apply-bulk-actions": "تطبيق الاجراءات الجماعية",
|
||||
"recent-changes-in-subtree": "التغييرات الاخيرة في الشجرة الفرعية"
|
||||
},
|
||||
"note_types": {
|
||||
"text": "نص",
|
||||
@@ -884,7 +917,8 @@
|
||||
"quick-search": {
|
||||
"searching": "جار البحث...",
|
||||
"placeholder": "البحث السريع",
|
||||
"no-results": "لم يتم العثور على نتائج"
|
||||
"no-results": "لم يتم العثور على نتائج",
|
||||
"show-in-full-search": "عرض في البحث الكامل"
|
||||
},
|
||||
"note_tree": {
|
||||
"unhoist": "ارجاع الى الترتيب الطبيعي",
|
||||
@@ -893,7 +927,12 @@
|
||||
"collapse-title": "طي شجرة الملاحظة",
|
||||
"hide-archived-notes": "اخفاء الملاحظات المؤرشفة",
|
||||
"automatically-collapse-notes": "طي الملاحظات تلقائيا",
|
||||
"create-child-note": "انشاء ملاحظة فرعية"
|
||||
"create-child-note": "انشاء ملاحظة فرعية",
|
||||
"scroll-active-title": "تمرير الى الملاحظة النشطة",
|
||||
"save-changes": "حفظ وتطبيق التغييرات",
|
||||
"saved-search-note-refreshed": "تم تحديث ملاحظة البحث المحفوظة.",
|
||||
"hoist-this-note-workspace": "تثبيت هذه الملاحظة (مساحة العمل)",
|
||||
"refresh-saved-search-results": "تحديث نتائج البحث المحفوظة"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "جداول"
|
||||
@@ -901,7 +940,13 @@
|
||||
"launcher_context_menu": {
|
||||
"reset": "اعادة ضبط",
|
||||
"add-spacer": "اضافة فاصل",
|
||||
"delete": "حذف\n<kbd data-command=\"deleteNotes\">"
|
||||
"delete": "حذف\n<kbd data-command=\"deleteNotes\">",
|
||||
"add-note-launcher": "اضافة مشغل الملاحظة",
|
||||
"add-script-launcher": "اضافة مشغل السكريبت",
|
||||
"add-custom-widget": "اضافة عنصر واجهة مخصص",
|
||||
"move-to-visible-launchers": "نقل الى المشغلات المرئية",
|
||||
"move-to-available-launchers": "نقل الى المشغلات المتوفرة",
|
||||
"duplicate-launcher": "تكرار المشغل <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "تم اكتشافه تلقائيا"
|
||||
@@ -927,7 +972,9 @@
|
||||
"cut": "قص",
|
||||
"copy": "نسخ",
|
||||
"paste": "لصق",
|
||||
"copy-link": "نسخ الرابط"
|
||||
"copy-link": "نسخ الرابط",
|
||||
"add-term-to-dictionary": "اضافة \"{{term}}\" الى القاموس",
|
||||
"paste-as-plain-text": "لصق كنص عادي"
|
||||
},
|
||||
"promoted_attributes": {
|
||||
"url_placeholder": "http://website...",
|
||||
@@ -977,7 +1024,11 @@
|
||||
"totp_secret_regenerate": "اعادة توليد TOTP السري",
|
||||
"totp_secret_generated": "تم انشاء TOTP السري",
|
||||
"oauth_missing_vars": "اعدادات مفقودة: {{-variables}}",
|
||||
"totp_secret_title": "توليد TOTP سري"
|
||||
"totp_secret_title": "توليد TOTP سري",
|
||||
"totp_title": "كلمة مرور لمرة واحدة معتمدة على الوقت (TOTP)",
|
||||
"recovery_keys_title": "مفاتيح استرداد تسجيل الدخول الاحادي",
|
||||
"recovery_keys_error": "حدث خطأ اثناء توليد رموز الاسترجاع",
|
||||
"recovery_keys_no_key_set": "لاتوجد رموز استرجاع معينة"
|
||||
},
|
||||
"execute_script": {
|
||||
"execute_script": "تنفيذ السكريبت"
|
||||
@@ -1001,7 +1052,8 @@
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "حذف الملاحظة",
|
||||
"delete_matched_notes": "حف الملاحظات المطابقة"
|
||||
"delete_matched_notes": "حف الملاحظات المطابقة",
|
||||
"delete_matched_notes_description": "سوف يؤدي هذا الى حذف الملاحظات المطابقة."
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "اعادة تسمية الملاحظة",
|
||||
@@ -1119,7 +1171,12 @@
|
||||
"title": "اخفاء هوية البيانات",
|
||||
"full_anonymization": "الاخفاء الكامل للهوية",
|
||||
"light_anonymization": "الاخفاء الجزئي للهوية",
|
||||
"existing_anonymized_databases": "قواعد البيانات المجهولة الحالية"
|
||||
"existing_anonymized_databases": "قواعد البيانات المجهولة الحالية",
|
||||
"save_fully_anonymized_database": "حفظ قاعدة البيانات بعد اخفاء كل الهويات",
|
||||
"save_lightly_anonymized_database": "حفظ قاعدةةبيانات مخفية جزئيا",
|
||||
"creating_fully_anonymized_database": "انشاء قاعدة بيانات مجهولة بالكامل",
|
||||
"creating_lightly_anonymized_database": "انشاء قاعدةة بيانات مجهولة جزئيا...",
|
||||
"no_anonymized_database_yet": "لاتوجد قاعدة بيانات مجهولة بعد."
|
||||
},
|
||||
"vacuum_database": {
|
||||
"title": "تحرير مساحة قاعدة البيانات",
|
||||
@@ -1146,7 +1203,8 @@
|
||||
"italic": "نص مائل",
|
||||
"underline": "خط تحت النص",
|
||||
"color": "نص ملون",
|
||||
"visibility_title": "اظهار قائمة التضليلات"
|
||||
"visibility_title": "اظهار قائمة التضليلات",
|
||||
"bg_color": "نص مع لون خلفية"
|
||||
},
|
||||
"revisions_button": {
|
||||
"note_revisions": "مراجعات الملاحظة"
|
||||
@@ -1163,7 +1221,8 @@
|
||||
"title": "التدقيق الاملائي",
|
||||
"enable": "تفعيل التدقيق الاملائي",
|
||||
"language_code_label": "رمز اللغة او رموز اللغات",
|
||||
"available_language_codes_label": "رموز اللغات المتاحة:"
|
||||
"available_language_codes_label": "رموز اللغات المتاحة:",
|
||||
"language_code_placeholder": "على سبيل المثال \"en-US\", \"de-AI\""
|
||||
},
|
||||
"note-map": {
|
||||
"button-link-map": "خريطة الروابط",
|
||||
@@ -1177,7 +1236,9 @@
|
||||
},
|
||||
"branches": {
|
||||
"delete-status": "حالة الحذف",
|
||||
"delete-finished-successfully": "تم الحذف بنجاح."
|
||||
"delete-finished-successfully": "تم الحذف بنجاح.",
|
||||
"cannot-move-notes-here": "لايمكن نقل الملاحظات الى هنا.",
|
||||
"undeleting-notes-finished-successfully": "تم استرجاع الملاحظات بنجاح."
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "كتل الكود",
|
||||
@@ -1199,14 +1260,16 @@
|
||||
"native-title-bar": "شريط العنوان الاصلي"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"quick-edit": "التحرير السريع"
|
||||
"quick-edit": "التحرير السريع",
|
||||
"note-has-been-deleted": "تم حذف الملاحظة."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "فتح الموقع",
|
||||
"remove-from-map": "ازالة من الخريطة"
|
||||
},
|
||||
"share": {
|
||||
"title": "اعدادات المشاركة"
|
||||
"title": "اعدادات المشاركة",
|
||||
"check_share_root": "التحقق من حالة جذر المشاركة"
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "غير محدد",
|
||||
@@ -1251,7 +1314,8 @@
|
||||
"search_subtree_title": "بحث في الشجرة الفرعية",
|
||||
"search_history_title": "عرص سجل البحث",
|
||||
"search_history_description": "عرض البحث السابق",
|
||||
"configure_launch_bar_title": "تكوين شريط الاطلاق"
|
||||
"configure_launch_bar_title": "تكوين شريط الاطلاق",
|
||||
"search_subtree_description": "البحث ضمن الشجرة الفرعية الحالية"
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "فتح خارجيا"
|
||||
@@ -1272,7 +1336,8 @@
|
||||
"notes_to_move": "الملاحظات المراد نقلها",
|
||||
"target_parent_note": "ملاحظة الاصل الهدف",
|
||||
"dialog_title": "انقل الملاحظات الى...",
|
||||
"move_button": "نقل الىالملاحظة المحددة"
|
||||
"move_button": "نقل الىالملاحظة المحددة",
|
||||
"error_no_path": "لايوجد مسار لنقل العنصر الية."
|
||||
},
|
||||
"delete_revisions": {
|
||||
"delete_note_revisions": "حذف مراجعات الملاحظة"
|
||||
@@ -1295,7 +1360,8 @@
|
||||
"database_integrity_check": {
|
||||
"title": "فحص سلامة قاعدة البيانات",
|
||||
"check_button": "التحقق من سلامة قاعدة البيانات",
|
||||
"checking_integrity": "جار التحقق من سلامة قاعدة البيانات..."
|
||||
"checking_integrity": "جار التحقق من سلامة قاعدة البيانات...",
|
||||
"integrity_check_failed": "فشل التحقق من السلامة: {{results}}"
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"upload_modified_file": "رفع الملف المعدل",
|
||||
@@ -1322,13 +1388,15 @@
|
||||
"save_attributes": "حفظ السمات <enter>",
|
||||
"add_a_new_attribute": "اضافة سمة جديدة",
|
||||
"add_new_label_definition": "اضافة تعريف لتسمية جديدة",
|
||||
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة"
|
||||
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة",
|
||||
"add_new_relation": "اضافة علاقة جديدة <kbd data-command=\"addNewRelation\">"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "الخروج من وضع Zen"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "مهلة مسح المرفقات"
|
||||
"attachment_erasure_timeout": "مهلة مسح المرفقات",
|
||||
"erase_attachments_after": "حذف المرفقات الغير مستخدمة بعد:"
|
||||
},
|
||||
"note_erasure_timeout": {
|
||||
"note_erasure_timeout_title": "مهلة مسح الملاحظة",
|
||||
@@ -1366,5 +1434,34 @@
|
||||
},
|
||||
"revisions_snapshot_interval": {
|
||||
"note_revisions_snapshot_interval_title": "الفاصل الزمني لنسخ الملاحظات الاحتياطية"
|
||||
},
|
||||
"note_detail": {
|
||||
"printing": "جار الطباعة ..."
|
||||
},
|
||||
"attachment_detail_2": {
|
||||
"role_and_size": "الدور: {{role}}، الحجم: {{size}}",
|
||||
"unrecognized_role": "دور المرفق '{{role}}'الغير معروف."
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "ابقاء النافذة في الاعلى"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "اكتب عنوان الملاحظة هنا..."
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "نسخ المرجع الى الحافظة",
|
||||
"copy_image_to_clipboard": "نسخ الصورة الى الحافظة"
|
||||
},
|
||||
"geo-map": {
|
||||
"unable-to-load-map": "تعذر تحميل الخريطة."
|
||||
},
|
||||
"content_widget": {
|
||||
"unknown_widget": "عنصر واجهة غير معروف للمعرف \"{{id}}\"."
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "تصدير المخطط كملف PNG"
|
||||
},
|
||||
"protected_session_status": {
|
||||
"inactive": "انقر للدخول الى جلسة محمية"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
|
||||
@@ -184,7 +184,8 @@
|
||||
},
|
||||
"import-status": "Importstatus",
|
||||
"in-progress": "Import läuft: {{progress}}",
|
||||
"successful": "Import erfolgreich abgeschlossen."
|
||||
"successful": "Import erfolgreich abgeschlossen.",
|
||||
"importZipRecommendation": "Beim Import einer ZIP-Datei wird die Notizhierarchie aus der Ordnerstruktur im Archiv übernommen."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Notiz beifügen",
|
||||
@@ -647,7 +648,8 @@
|
||||
"logout": "Abmelden",
|
||||
"show-cheatsheet": "Cheatsheet anzeigen",
|
||||
"toggle-zen-mode": "Zen Modus",
|
||||
"new-version-available": "Neues Update verfügbar"
|
||||
"new-version-available": "Neues Update verfügbar",
|
||||
"download-update": "Version {{latestVersion}} herunterladen"
|
||||
},
|
||||
"sync_status": {
|
||||
"unknown": "<p>Der Synchronisations-Status wird bekannt, sobald der nächste Synchronisierungsversuch gestartet wird.</p><p>Klicke, um eine Synchronisierung jetzt auszulösen.</p>",
|
||||
@@ -987,7 +989,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.",
|
||||
@@ -1521,7 +1523,9 @@
|
||||
"window-on-top": "Dieses Fenster immer oben halten"
|
||||
},
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden"
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden",
|
||||
"printing": "Druckvorgang läuft…",
|
||||
"printing_pdf": "PDF-Export läuft…"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "Titel der Notiz hier eingeben…"
|
||||
@@ -2079,6 +2083,7 @@
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Folie bearbeiten",
|
||||
"start-presentation": "Präsentation starten"
|
||||
"start-presentation": "Präsentation starten",
|
||||
"slide-overview": "Übersicht der Folien ein-/ausblenden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,7 +992,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.",
|
||||
|
||||
@@ -991,7 +991,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.",
|
||||
|
||||
@@ -184,7 +184,8 @@
|
||||
},
|
||||
"import-status": "Statut de l'importation",
|
||||
"in-progress": "Importation en cours : {{progress}}",
|
||||
"successful": "Importation terminée avec succès."
|
||||
"successful": "Importation terminée avec succès.",
|
||||
"importZipRecommendation": "Lors de l'importation d'un fichier ZIP, la hiérarchie des notes reflétera la structure des sous-répertoires au sein de l'archive."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Inclure une note",
|
||||
@@ -279,8 +280,8 @@
|
||||
"delete_button": "Supprimer",
|
||||
"diff_on": "Afficher les différences",
|
||||
"diff_off": "Afficher le contenu",
|
||||
"diff_on_hint": "Cliquez pour afficher les différences de la note d'origine",
|
||||
"diff_off_hint": "Cliquez pour afficher le contenu de la note",
|
||||
"diff_on_hint": "Cliquer pour afficher les différences avec la note d'origine",
|
||||
"diff_off_hint": "Cliquer pour afficher le contenu de la note",
|
||||
"diff_not_available": "La comparaison n'est pas disponible."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
@@ -646,7 +647,9 @@
|
||||
"about": "À propos de Trilium Notes",
|
||||
"logout": "Déconnexion",
|
||||
"show-cheatsheet": "Afficher l'aide rapide",
|
||||
"toggle-zen-mode": "Zen Mode"
|
||||
"toggle-zen-mode": "Zen Mode",
|
||||
"new-version-available": "Nouvelle mise à jour disponible",
|
||||
"download-update": "Obtenir la version {{latestVersion}}"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "Sortir du Zen mode"
|
||||
@@ -673,7 +676,7 @@
|
||||
"search_in_note": "Rechercher dans la note",
|
||||
"note_source": "Code source",
|
||||
"note_attachments": "Pièces jointes",
|
||||
"open_note_externally": "Ouverture externe",
|
||||
"open_note_externally": "Ouvrir la note en externe",
|
||||
"open_note_externally_title": "Le fichier sera ouvert dans une application externe et les modifications apportées seront surveillées. Vous pourrez ensuite téléverser la version modifiée dans Trilium.",
|
||||
"open_note_custom": "Ouvrir la note avec",
|
||||
"import_files": "Importer des fichiers",
|
||||
@@ -766,7 +769,8 @@
|
||||
"table": "Tableau",
|
||||
"geo-map": "Carte géographique",
|
||||
"board": "Tableau de bord",
|
||||
"include_archived_notes": "Afficher les notes archivées"
|
||||
"include_archived_notes": "Afficher les notes archivées",
|
||||
"presentation": "Présentation"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "Aucune note modifiée ce jour-là...",
|
||||
@@ -988,7 +992,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.",
|
||||
@@ -1141,7 +1145,8 @@
|
||||
"code_auto_read_only_size": {
|
||||
"title": "Taille pour la lecture seule automatique",
|
||||
"description": "La taille pour la lecture seule automatique est le seuil au-delà de laquelle les notes seront affichées en mode lecture seule (pour optimiser les performances).",
|
||||
"label": "Taille pour la lecture seule automatique (notes de code)"
|
||||
"label": "Taille pour la lecture seule automatique (notes de code)",
|
||||
"unit": "caractères"
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "Types MIME disponibles dans la liste déroulante"
|
||||
@@ -1160,7 +1165,8 @@
|
||||
"download_images_description": "Le HTML collé peut contenir des références à des images en ligne, Trilium trouvera ces références et téléchargera les images afin qu'elles soient disponibles hors ligne.",
|
||||
"enable_image_compression": "Activer la compression des images",
|
||||
"max_image_dimensions": "Largeur/hauteur maximale d'une image en pixels (l'image sera redimensionnée si elle dépasse ce paramètre).",
|
||||
"jpeg_quality_description": "Qualité JPEG (10 - pire qualité, 100 - meilleure qualité, 50 - 85 est recommandé)"
|
||||
"jpeg_quality_description": "Qualité JPEG (10 - pire qualité, 100 - meilleure qualité, 50 - 85 est recommandé)",
|
||||
"max_image_dimensions_unit": "pixels"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Délai d'effacement des pièces jointes",
|
||||
@@ -1192,7 +1198,8 @@
|
||||
"note_revisions_snapshot_limit_description": "La limite du nombre de versions de note désigne le nombre maximum de versions pouvant être enregistrées pour chaque note. -1 signifie aucune limite, 0 signifie supprimer toutes les versions. Vous pouvez définir le nombre maximal de versions pour une seule note avec le label #versioningLimit.",
|
||||
"snapshot_number_limit_label": "Nombre limite de versions de note :",
|
||||
"erase_excess_revision_snapshots": "Effacer maintenant les versions en excès",
|
||||
"erase_excess_revision_snapshots_prompt": "Les versions en excès ont été effacées."
|
||||
"erase_excess_revision_snapshots_prompt": "Les versions en excès ont été effacées.",
|
||||
"snapshot_number_limit_unit": "instantanés"
|
||||
},
|
||||
"search_engine": {
|
||||
"title": "Moteur de recherche",
|
||||
@@ -1234,19 +1241,35 @@
|
||||
"title": "Table des matières",
|
||||
"description": "La table des matières apparaîtra dans les notes textuelles lorsque la note comporte plus d'un nombre défini de titres. Vous pouvez personnaliser ce nombre :",
|
||||
"disable_info": "Vous pouvez également utiliser cette option pour désactiver la table des matières en définissant un nombre très élevé.",
|
||||
"shortcut_info": "Vous pouvez configurer un raccourci clavier pour afficher/masquer le volet de droite (y compris la table des matières) dans Options -> Raccourcis (nom « toggleRightPane »)."
|
||||
"shortcut_info": "Vous pouvez configurer un raccourci clavier pour afficher/masquer le volet de droite (y compris la table des matières) dans Options -> Raccourcis (nom « toggleRightPane »).",
|
||||
"unit": "titres"
|
||||
},
|
||||
"text_auto_read_only_size": {
|
||||
"title": "Taille automatique en lecture seule",
|
||||
"description": "La taille automatique des notes en lecture seule est la taille au-delà de laquelle les notes seront affichées en mode lecture seule (pour des raisons de performances).",
|
||||
"label": "Taille automatique en lecture seule (notes de texte)"
|
||||
"label": "Taille automatique en lecture seule (notes de texte)",
|
||||
"unit": "caractères"
|
||||
},
|
||||
"i18n": {
|
||||
"title": "Paramètres régionaux",
|
||||
"language": "Langue",
|
||||
"first-day-of-the-week": "Premier jour de la semaine",
|
||||
"sunday": "Dimanche",
|
||||
"monday": "Lundi"
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"first-week-of-the-year": "Première semaine de l'année",
|
||||
"first-week-contains-first-day": "La première semaine contient le premier jour de l'année",
|
||||
"first-week-contains-first-thursday": "La première semaine contient le premier jeudi de l'année",
|
||||
"first-week-has-minimum-days": "La première semaine a un nombre minimum de jours",
|
||||
"min-days-in-first-week": "Nombre minimum de jours dans la première semaine",
|
||||
"first-week-info": "La première semaine contient le premier jeudi de l'année et est basée sur la norme <a href=\"https://en.wikipedia.org/wiki/ISO_week_date#First_week\">ISO 8601</a> .",
|
||||
"first-week-warning": "La modification des options de la première semaine peut entraîner des doublons avec les notes de semaine existantes et les notes de semaine existantes ne seront pas mises à jour en conséquence.",
|
||||
"formatting-locale": "Format de date et de nombre",
|
||||
"formatting-locale-auto": "En fonction de la langue de l'application"
|
||||
},
|
||||
"backup": {
|
||||
"automatic_backup": "Sauvegarde automatique",
|
||||
@@ -1284,7 +1307,9 @@
|
||||
"delete_token": "Supprimer/désactiver ce token",
|
||||
"rename_token_title": "Renommer le jeton",
|
||||
"rename_token_message": "Veuillez saisir le nom du nouveau jeton",
|
||||
"delete_token_confirmation": "Êtes-vous sûr de vouloir supprimer le jeton ETAPI « {{name}} » ?"
|
||||
"delete_token_confirmation": "Êtes-vous sûr de vouloir supprimer le jeton ETAPI « {{name}} » ?",
|
||||
"see_more": "Voir plus de détails dans le {{- link_to_wiki}} et le {{- link_to_openapi_spec}} ou le {{- link_to_swagger_ui }}.",
|
||||
"swagger_ui": "Interface utilisateur ETAPI Swagger"
|
||||
},
|
||||
"options_widget": {
|
||||
"options_status": "Statut des options",
|
||||
@@ -1347,7 +1372,8 @@
|
||||
"test_title": "Test de synchronisation",
|
||||
"test_description": "Testera la connexion et la prise de contact avec le serveur de synchronisation. Si le serveur de synchronisation n'est pas initialisé, cela le configurera pour qu'il se synchronise avec le document local.",
|
||||
"test_button": "Tester la synchronisation",
|
||||
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}"
|
||||
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}",
|
||||
"timeout_unit": "millisecondes"
|
||||
},
|
||||
"api_log": {
|
||||
"close": "Fermer"
|
||||
@@ -1407,11 +1433,14 @@
|
||||
"import-into-note": "Importer dans la note",
|
||||
"apply-bulk-actions": "Appliquer des Actions groupées",
|
||||
"converted-to-attachments": "Les notes {{count}} ont été converties en pièces jointes.",
|
||||
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?"
|
||||
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Désarchiver",
|
||||
"open-in-popup": "Modification rapide"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}",
|
||||
"shared_locally": "Cette note est partagée localement sur {{- link}}",
|
||||
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}.",
|
||||
"shared_locally": "Cette note est partagée localement sur {{- link}}.",
|
||||
"help_link": "Pour obtenir de l'aide, visitez le <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a>."
|
||||
},
|
||||
"note_types": {
|
||||
@@ -1433,7 +1462,11 @@
|
||||
"confirm-change": "Il n'est pas recommandé de modifier le type de note lorsque son contenu n'est pas vide. Voulez-vous continuer ?",
|
||||
"geo-map": "Carte géo",
|
||||
"beta-feature": "Beta",
|
||||
"task-list": "Liste de tâches"
|
||||
"task-list": "Liste de tâches",
|
||||
"book": "Collection",
|
||||
"ai-chat": "Chat IA",
|
||||
"new-feature": "Nouveau",
|
||||
"collections": "Collections"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Protéger la note",
|
||||
@@ -1486,13 +1519,16 @@
|
||||
"hoist-this-note-workspace": "Focus cette note (espace de travail)",
|
||||
"refresh-saved-search-results": "Rafraîchir les résultats de recherche enregistrée",
|
||||
"create-child-note": "Créer une note enfant",
|
||||
"unhoist": "Désactiver le focus"
|
||||
"unhoist": "Désactiver le focus",
|
||||
"toggle-sidebar": "Basculer la barre latérale"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Épingler cette fenêtre au premier plan"
|
||||
},
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Impossible de trouver typeWidget pour le type '{{type}}'"
|
||||
"could_not_find_typewidget": "Impossible de trouver typeWidget pour le type '{{type}}'",
|
||||
"printing": "Impression en cours...",
|
||||
"printing_pdf": "Export au format PDF en cours..."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "saisir le titre de la note ici..."
|
||||
@@ -1543,7 +1579,9 @@
|
||||
},
|
||||
"clipboard": {
|
||||
"cut": "Les note(s) ont été coupées dans le presse-papiers.",
|
||||
"copied": "Les note(s) ont été coupées dans le presse-papiers."
|
||||
"copied": "Les note(s) ont été coupées dans le presse-papiers.",
|
||||
"copy_failed": "Impossible de copier dans le presse-papiers en raison de problèmes d'autorisation.",
|
||||
"copy_success": "Copié dans le presse-papiers."
|
||||
},
|
||||
"entrypoints": {
|
||||
"note-revision-created": "La version de la note a été créée.",
|
||||
@@ -1565,7 +1603,9 @@
|
||||
"ws": {
|
||||
"sync-check-failed": "Le test de synchronisation a échoué !",
|
||||
"consistency-checks-failed": "Les tests de cohérence ont échoué ! Consultez les journaux pour plus de détails.",
|
||||
"encountered-error": "Erreur \"{{message}}\", consultez la console."
|
||||
"encountered-error": "Erreur \"{{message}}\", consultez la console.",
|
||||
"lost-websocket-connection-title": "Connexion au serveur perdue",
|
||||
"lost-websocket-connection-message": "Vérifiez la configuration de votre proxy inverse (par exemple nginx ou Apache) pour vous assurer que les connexions WebSocket sont correctement autorisées et ne sont pas bloquées."
|
||||
},
|
||||
"hoisted_note": {
|
||||
"confirm_unhoisting": "La note demandée «{{requestedNote}}» est en dehors du sous-arbre de la note focus «{{hoistedNote}}». Le focus doit être désactivé pour accéder à la note. Voulez-vous enlever le focus ?"
|
||||
@@ -1587,13 +1627,15 @@
|
||||
},
|
||||
"highlighting": {
|
||||
"description": "Contrôle la coloration syntaxique des blocs de code à l'intérieur des notes texte, les notes de code ne seront pas affectées.",
|
||||
"color-scheme": "Jeu de couleurs"
|
||||
"color-scheme": "Jeu de couleurs",
|
||||
"title": "Blocs de code"
|
||||
},
|
||||
"code_block": {
|
||||
"word_wrapping": "Saut à la ligne automatique suivant la largeur",
|
||||
"theme_none": "Pas de coloration syntaxique",
|
||||
"theme_group_light": "Thèmes clairs",
|
||||
"theme_group_dark": "Thèmes sombres"
|
||||
"theme_group_dark": "Thèmes sombres",
|
||||
"copy_title": "Copier dans le presse-papiers"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "Mise en forme"
|
||||
@@ -1652,7 +1694,8 @@
|
||||
"full-text-search": "Recherche dans le texte"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La note a été supprimée."
|
||||
"note-has-been-deleted": "La note a été supprimée.",
|
||||
"quick-edit": "Edition rapide"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Créer une nouvelle note enfant et l'ajouter à la carte",
|
||||
@@ -1661,7 +1704,8 @@
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Ouvrir la position",
|
||||
"remove-from-map": "Retirer de la carte"
|
||||
"remove-from-map": "Retirer de la carte",
|
||||
"add-note": "Ajouter un marqueur à cet endroit"
|
||||
},
|
||||
"help-button": {
|
||||
"title": "Ouvrir la page d'aide correspondante"
|
||||
@@ -1688,10 +1732,41 @@
|
||||
"minimum_input": "La valeur de temps saisie doit être d'au moins {{minimumSeconds}} secondes."
|
||||
},
|
||||
"multi_factor_authentication": {
|
||||
"oauth_user_email": "Courriel de l'utilisateur : "
|
||||
"oauth_user_email": "Courriel de l'utilisateur : ",
|
||||
"title": "Authentification multifacteur",
|
||||
"description": "L'authentification multifacteur (MFA) renforce la sécurité de votre compte. Au lieu de simplement saisir un mot de passe pour vous connecter, le MFA vous demande de fournir une ou plusieurs preuves supplémentaires pour vérifier votre identité. Ainsi, même si quelqu'un obtient votre mot de passe, il ne peut accéder à votre compte sans cette deuxième information. C'est comme ajouter une serrure supplémentaire à votre porte, rendant l'effraction beaucoup plus difficile.<br><br>Veuillez suivre les instructions ci-dessous pour activer le MFA. Si vous ne configurez pas correctement, la connexion se fera uniquement par mot de passe.",
|
||||
"mfa_enabled": "Activer l'authentification multifacteur",
|
||||
"mfa_method": "Méthode MFA",
|
||||
"electron_disabled": "L'authentification multifacteur n'est actuellement pas prise en charge dans la version de bureau.",
|
||||
"totp_title": "Mot de passe à usage unique basé sur le temps (TOTP)",
|
||||
"totp_description": "Le TOTP (Time-Based One-Time Password) est une fonctionnalité de sécurité qui génère un code unique et temporaire, modifié toutes les 30 secondes. Vous utilisez ce code, associé à votre mot de passe, pour vous connecter à votre compte, ce qui rend l'accès à celui-ci beaucoup plus difficile.",
|
||||
"totp_secret_title": "Générer un secret TOTP",
|
||||
"totp_secret_generate": "Générer un secret TOTP",
|
||||
"totp_secret_regenerate": "Re-générer un secret TOTP",
|
||||
"no_totp_secret_warning": "Pour activer TOTP, vous devez d’abord générer un secret TOTP.",
|
||||
"totp_secret_description_warning": "Après avoir généré un nouveau secret TOTP, vous devrez vous reconnecter avec le nouveau secret TOTP.",
|
||||
"totp_secret_generated": "Secret TOTP généré",
|
||||
"totp_secret_warning": "Veuillez conserver le secret généré dans un endroit sûr. Il ne sera plus affiché.",
|
||||
"totp_secret_regenerate_confirm": "Voulez-vous vraiment régénérer le secret TOTP ? Cela invalidera le secret TOTP précédent et tous les codes de récupération existants.",
|
||||
"recovery_keys_title": "Clés de récupération d'authentification unique",
|
||||
"recovery_keys_description": "Les clés de récupération d'authentification unique sont utilisées pour vous connecter même si vous ne pouvez pas accéder à vos codes d'authentification.",
|
||||
"recovery_keys_description_warning": "Les clés de récupération ne seront plus affichées après avoir quitté la page, conservez-les dans un endroit sûr et sécurisé.<br>Une fois qu'une clé de récupération a été utilisée, elle devient inutilisable.",
|
||||
"recovery_keys_error": "Erreur lors de la génération des codes de récupération",
|
||||
"recovery_keys_no_key_set": "Aucun code de récupération défini",
|
||||
"recovery_keys_generate": "Générer des codes de récupération",
|
||||
"recovery_keys_regenerate": "Re-générer des codes de récupération",
|
||||
"recovery_keys_used": "Utilisé : {{date}}",
|
||||
"recovery_keys_unused": "Le code de récupération {{index}} n'est pas utilisé",
|
||||
"oauth_title": "OAuth/OpenID",
|
||||
"oauth_description": "OpenID est un moyen standardisé de vous connecter à des sites web avec un compte d'un autre service, comme Google, afin de vérifier votre identité. L'émetteur par défaut est Google, mais vous pouvez le modifier pour n'importe quel autre fournisseur OpenID. Consultez <a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">ici</a> pour plus d'informations. Suivez ces <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">instructions</a> pour configurer un service OpenID via Google.",
|
||||
"oauth_description_warning": "Pour activer OAuth/OpenID, vous devez définir l'URL de base, l'ID client et le secret client OAuth/OpenID dans le fichier config.ini, puis redémarrer l'application. Pour les définir à partir des variables d'environnement, définissez TRILIUM_OAUTH_BASE_URL, TRILIUM_OAUTH_CLIENT_ID et TRILIUM_OAUTH_CLIENT_SECRET.",
|
||||
"oauth_missing_vars": "Paramètres manquants : {{-variables}}",
|
||||
"oauth_user_account": "Compte utilisateur: ",
|
||||
"oauth_user_not_logged_in": "Pas connecté !"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Fermer"
|
||||
"close": "Fermer",
|
||||
"help_title": "Afficher plus d'informations sur cet écran"
|
||||
},
|
||||
"ai_llm": {
|
||||
"not_started": "Non démarré",
|
||||
@@ -1771,13 +1846,76 @@
|
||||
"reprocessing_index": "Mise à jour...",
|
||||
"reprocess_index_started": "L'optimisation de l'indice de recherche à commencer en arrière-plan",
|
||||
"reprocess_index_error": "Erreur dans le rafraichissement de l'indice de recherche",
|
||||
"failed_notes": "Notes échouées",
|
||||
"failed_notes": "Notes en erreur",
|
||||
"last_processed": "Dernier traitement",
|
||||
"restore_provider": "Restaurer le fournisseur de la recherche",
|
||||
"restore_provider": "Restaurer le fournisseur de recherche",
|
||||
"index_rebuild_progress": "Progression de la reconstruction de l'index",
|
||||
"index_rebuilding": "Optimisation de l'index ({{percentage}}%)",
|
||||
"index_rebuild_complete": "Optimisation de l'index terminée",
|
||||
"index_rebuild_status_error": "Erreur lors de la vérification de l'état de reconstruction de l'index"
|
||||
"index_rebuild_status_error": "Erreur lors de la vérification de l'état de reconstruction de l'index",
|
||||
"provider_precedence": "Priorité du fournisseur",
|
||||
"never": "Jamais",
|
||||
"processing": "Traitement en cours ({{percentage}}%)",
|
||||
"incomplete": "Incomplet ({{percentage}}%)",
|
||||
"complete": "Terminé (100%)",
|
||||
"refreshing": "Mise à jour...",
|
||||
"auto_refresh_notice": "Actualisation automatique toutes les {{seconds}} secondes",
|
||||
"note_queued_for_retry": "Note mise en file d'attente pour une nouvelle tentative",
|
||||
"failed_to_retry_note": "Échec de la nouvelle tentative de note",
|
||||
"all_notes_queued_for_retry": "Toutes les notes ayant échoué sont mises en file d'attente pour une nouvelle tentative",
|
||||
"failed_to_retry_all": "Échec du ré essai des notes",
|
||||
"ai_settings": "Paramètres IA",
|
||||
"api_key_tooltip": "Clé API pour accéder au service",
|
||||
"empty_key_warning": {
|
||||
"anthropic": "La clé API Anthropic est vide. Veuillez saisir une clé API valide.",
|
||||
"openai": "La clé API OpenAI est vide. Veuillez saisir une clé API valide.",
|
||||
"voyage": "La clé API Voyage est vide. Veuillez saisir une clé API valide.",
|
||||
"ollama": "La clé API Ollama est vide. Veuillez saisir une clé API valide."
|
||||
},
|
||||
"agent": {
|
||||
"processing": "Traitement...",
|
||||
"thinking": "Réflexion...",
|
||||
"loading": "Chargement...",
|
||||
"generating": "Génération..."
|
||||
},
|
||||
"name": "IA",
|
||||
"openai": "OpenAI",
|
||||
"use_enhanced_context": "Utiliser un contexte amélioré",
|
||||
"enhanced_context_description": "Fournit à l'IA plus de contexte à partir de la note et de ses notes associées pour de meilleures réponses",
|
||||
"show_thinking": "Montrer la réflexion",
|
||||
"show_thinking_description": "Montrer la chaîne de pensée de l'IA",
|
||||
"enter_message": "Entrez votre message...",
|
||||
"error_contacting_provider": "Erreur lors de la connexion au fournisseur d'IA. Veuillez vérifier vos paramètres et votre connexion Internet.",
|
||||
"error_generating_response": "Erreur lors de la génération de la réponse de l'IA",
|
||||
"index_all_notes": "Indexer toutes les notes",
|
||||
"index_status": "Statut de l'index",
|
||||
"indexed_notes": "Notes indexées",
|
||||
"indexing_stopped": "Arrêt de l'indexation",
|
||||
"indexing_in_progress": "Indexation en cours...",
|
||||
"last_indexed": "Dernière indexée",
|
||||
"note_chat": "Note discussion",
|
||||
"sources": "Sources",
|
||||
"start_indexing": "Démarrage de l'indexation",
|
||||
"use_advanced_context": "Utiliser le contexte avancé",
|
||||
"ollama_no_url": "Ollama n'est pas configuré. Veuillez saisir une URL valide.",
|
||||
"chat": {
|
||||
"root_note_title": "Discussions IA",
|
||||
"root_note_content": "Cette note contient vos conversations de chat IA enregistrées.",
|
||||
"new_chat_title": "Nouvelle discussion",
|
||||
"create_new_ai_chat": "Créer une nouvelle discussion IA"
|
||||
},
|
||||
"create_new_ai_chat": "Créer une nouvelle discussion IA",
|
||||
"configuration_warnings": "Il y a quelques problèmes avec la configuration de votre IA. Veuillez vérifier vos paramètres.",
|
||||
"experimental_warning": "La fonctionnalité LLM est actuellement expérimentale – vous êtes prévenu.",
|
||||
"selected_provider": "Fournisseur sélectionné",
|
||||
"selected_provider_description": "Choisissez le fournisseur d’IA pour les fonctionnalités de discussion et de complétion",
|
||||
"select_model": "Sélectionner le modèle...",
|
||||
"select_provider": "Sélectionnez un fournisseur...",
|
||||
"ai_enabled": "Fonctionnalités d'IA activées",
|
||||
"ai_disabled": "Fonctionnalités d'IA désactivées",
|
||||
"no_models_found_online": "Aucun modèle trouvé. Veuillez vérifier votre clé API et vos paramètres.",
|
||||
"no_models_found_ollama": "Aucun modèle Ollama trouvé. Veuillez vérifier si Ollama est en cours d'exécution.",
|
||||
"error_fetching": "Erreur lors de la récupération des modèles : {{error}}"
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "Performance",
|
||||
@@ -1786,5 +1924,168 @@
|
||||
"enable-backdrop-effects": "Activer les effets d'arrière plan pour les menus, popups et panneaux",
|
||||
"enable-smooth-scroll": "Active le défilement fluide",
|
||||
"app-restart-required": "(redémarrer l'application pour appliquer les changements)"
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "Format de date/heure personnalisé",
|
||||
"description": "Personnalisez le format de la date et de l'heure insérées via <shortcut /> ou la barre d'outils. Consultez la <doc>Day.js docs</doc> pour connaître les formats disponibles.",
|
||||
"format_string": "Chaîne de format :",
|
||||
"formatted_time": "Date/heure formatée :"
|
||||
},
|
||||
"table_view": {
|
||||
"delete_column_confirmation": "Êtes-vous sûr de vouloir supprimer cette colonne ? L'attribut correspondant sera supprimé de toutes les notes.",
|
||||
"delete-column": "Supprimer la colonne",
|
||||
"new-column-label": "Étiquette",
|
||||
"new-column-relation": "Relation",
|
||||
"edit-column": "Editer la colonne",
|
||||
"add-column-to-the-right": "Ajouter une colonne à droite",
|
||||
"new-row": "Nouvelle ligne",
|
||||
"new-column": "Nouvelle colonne",
|
||||
"sort-column-by": "Trier par « {{title}} »",
|
||||
"sort-column-ascending": "Ascendant",
|
||||
"sort-column-descending": "Descendant",
|
||||
"sort-column-clear": "Annuler le tri",
|
||||
"hide-column": "Masquer la colonne \"{{title}}\"",
|
||||
"show-hide-columns": "Afficher/masquer les colonnes",
|
||||
"row-insert-above": "Insérer une ligne au-dessus",
|
||||
"row-insert-below": "Insérer une ligne au-dessous",
|
||||
"row-insert-child": "Insérer une note enfant",
|
||||
"add-column-to-the-left": "Ajouter une colonne à gauche"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Masquer les week-ends",
|
||||
"display-week-numbers": "Afficher les numéros de semaine",
|
||||
"map-style": "Style de carte :",
|
||||
"max-nesting-depth": "Profondeur d'imbrication maximale :",
|
||||
"raster": "Trame",
|
||||
"vector_light": "Vecteur (clair)",
|
||||
"vector_dark": "Vecteur (foncé)",
|
||||
"show-scale": "Afficher l'échelle"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Supprimer la ligne"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "Supprimer la note...",
|
||||
"remove-from-board": "Retirer du tableau",
|
||||
"archive-note": "Note archivée",
|
||||
"unarchive-note": "Note désarchivée",
|
||||
"move-to": "Déplacer vers",
|
||||
"insert-above": "Insérer au-dessus",
|
||||
"insert-below": "Insérer au-dessous",
|
||||
"delete-column": "Supprimer la colonne",
|
||||
"delete-column-confirmation": "Êtes-vous sûr de vouloir supprimer cette colonne ? L'attribut correspondant sera également supprimé dans les notes sous cette colonne.",
|
||||
"new-item": "Nouvel article",
|
||||
"new-item-placeholder": "Entrez le titre de note...",
|
||||
"add-column": "Ajouter une colonne",
|
||||
"add-column-placeholder": "Entrez le nom de la colonne...",
|
||||
"edit-note-title": "Cliquez pour modifier le titre de la note",
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne"
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Modifier cette diapositive",
|
||||
"start-presentation": "Démarrer la présentation",
|
||||
"slide-overview": "Afficher un aperçu des diapositives"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Arborescence : {{name}}",
|
||||
"export_note_title": "Exporter la note",
|
||||
"export_note_description": "Exporter la note actuelle",
|
||||
"show_attachments_title": "Afficher les pièces jointes",
|
||||
"show_attachments_description": "Afficher les pièces jointes des notes",
|
||||
"search_notes_title": "Rechercher des notes",
|
||||
"search_notes_description": "Ouvrir la recherche avancée",
|
||||
"search_subtree_title": "Rechercher dans la sous-arborescence",
|
||||
"search_subtree_description": "Rechercher dans la sous-arborescence actuelle",
|
||||
"search_history_title": "Afficher l'historique de recherche",
|
||||
"search_history_description": "Afficher les recherches précédentes",
|
||||
"configure_launch_bar_title": "Configurer la barre de lancement",
|
||||
"configure_launch_bar_description": "Ouvrir la configuration de la barre de lancement pour ajouter ou supprimer des éléments."
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Ouverture externe"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "Essayez le nouveau thème Trilium",
|
||||
"next_theme_message": "Vous utilisez actuellement le thème hérité de l'ancienne version, souhaitez-vous essayer le nouveau thème ?",
|
||||
"next_theme_button": "Essayez le nouveau thème",
|
||||
"background_effects_title": "Les effets d'arrière-plan sont désormais stables",
|
||||
"background_effects_message": "Sur les appareils Windows, les effets d'arrière-plan sont désormais parfaitement stables. Ils ajoutent une touche de couleur à l'interface utilisateur en floutant l'arrière-plan. Cette technique est également utilisée dans d'autres applications comme l'Explorateur Windows.",
|
||||
"background_effects_button": "Activer les effets d'arrière-plan",
|
||||
"dismiss": "Rejeter"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "Paramètres associés"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Schéma de coloration syntaxique pour les blocs de code dans les notes de texte",
|
||||
"related_code_notes": "Schéma de couleurs pour les notes de code"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Page de {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notes"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Impossible d'afficher le contenu en raison d'une erreur."
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "Éditeur"
|
||||
},
|
||||
"tasks": {
|
||||
"due": {
|
||||
"today": "Aujourd'hui",
|
||||
"tomorrow": "Demain",
|
||||
"yesterday": "Hier"
|
||||
}
|
||||
},
|
||||
"content_widget": {
|
||||
"unknown_widget": "Widget inconnu pour « {{id}} »."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "Non défini",
|
||||
"configure-languages": "Configurer les langues..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Contenu des langues",
|
||||
"description": "Sélectionnez une ou plusieurs langues à afficher dans la section « Propriétés de base » d'une note textuelle en lecture seule ou modifiable. Cela permettra d'utiliser des fonctionnalités telles que la vérification orthographique ou la prise en charge de l'écriture de droite à gauche."
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "Déplacer le volet d'édition vers le bas",
|
||||
"title_horizontal": "Déplacer le panneau d'édition vers la gauche"
|
||||
},
|
||||
"toggle_read_only_button": {
|
||||
"unlock-editing": "Déverrouiller l'édition",
|
||||
"lock-editing": "Verrouiller l'édition"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "Exporter le diagramme au format PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "Le diagramme n'a pas pu être exporté au format PNG."
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "Apparence",
|
||||
"word_wrapping": "retour à la ligne automatique",
|
||||
"color-scheme": "Jeu de couleurs"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Veuillez télécharger la version ARM64",
|
||||
"message_macos": "TriliumNext fonctionne actuellement sous Rosetta 2, ce qui signifie que vous utilisez la version Intel (x64) sur un Mac Apple Silicon. Cela aura un impact significatif sur les performances et l'autonomie de la batterie.",
|
||||
"message_windows": "TriliumNext fonctionne actuellement en mode émulation, ce qui signifie que vous utilisez la version Intel (x64) sur un appareil Windows sur ARM. Cela aura un impact significatif sur les performances et l'autonomie de la batterie.",
|
||||
"recommendation": "Pour une expérience optimale, veuillez télécharger la version ARM64 native de TriliumNext depuis notre page de versions.",
|
||||
"download_link": "Télécharger la version native",
|
||||
"continue_anyway": "Continuer quand même",
|
||||
"dont_show_again": "Ne plus afficher cet avertissement"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caractéristiques",
|
||||
"emoji_completion_enabled": "Activer la saisie semi-automatique des emojis",
|
||||
"emoji_completion_description": "Si cette option est activée, les emojis peuvent être facilement insérés dans le texte en tapant `:` , suivi du nom d'un emoji.",
|
||||
"note_completion_enabled": "Activer la saisie semi-automatique des notes",
|
||||
"note_completion_description": "Si cette option est activée, des liens vers des notes peuvent être créés en tapant `@` suivi du titre d'une note.",
|
||||
"slash_commands_enabled": "Activer les commandes slash",
|
||||
"slash_commands_description": "Si cette option est activée, les commandes d'édition telles que l'insertion de sauts de ligne ou d'en-têtes peuvent être activées en tapant `/`."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,50 @@
|
||||
{}
|
||||
{
|
||||
"about": {
|
||||
"title": "A Trilium Notes-ról",
|
||||
"homepage": "Kezdőlap:",
|
||||
"app_version": "Alkalmazás verziója:",
|
||||
"db_version": "Adatbázis verzió:",
|
||||
"sync_version": "Verzió szinkronizálás :",
|
||||
"build_revision": "Build revízió:",
|
||||
"data_directory": "Adatkönyvtár:",
|
||||
"build_date": "Build dátum:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritikus hiba",
|
||||
"message": "Kritikus hiba történt, amely megakadályozza a kliensalkalmazás indítását:\n\n{{message}}\n\nEzt valószínűleg egy váratlan szkripthiba okozza. Próbálja meg biztonságos módban elindítani az alkalmazást, és hárítsa el a problémát."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Nem sikerült inicializálni egy widgetet",
|
||||
"message-custom": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó egyéni widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}",
|
||||
"message-unknown": "Ismeretlen widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Link hozzáadása",
|
||||
"help_on_links": "Segítség a linkekhez",
|
||||
"note": "Jegyzet",
|
||||
"search_note": "név szerinti jegyzetkeresés",
|
||||
"link_title_mirrors": "A link cím tükrözi a jegyzet aktuális címét",
|
||||
"link_title_arbitrary": "link cím önkényesen módosítható",
|
||||
"link_title": "Link cím",
|
||||
"button_add_link": "Link hozzáadása"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Az elágazás előtagjának szerkesztése",
|
||||
"help_on_tree_prefix": "Segítség a fa előtagján",
|
||||
"prefix": "Az előtag: ",
|
||||
"save": "Mentés"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Tömeges akciók",
|
||||
"affected_notes": "Érintett jegyzetek",
|
||||
"labels": "Címkék",
|
||||
"relations": "Kapcsolatok",
|
||||
"notes": "Jegyzetek"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
"emoji_completion_description": "Se abilitata, è possibile inserire facilmente gli emoji nel testo digitando `:`, seguito dal nome dell'emoji.",
|
||||
"note_completion_description": "Se abilitato, è possibile creare collegamenti alle note digitando `@` seguito dal titolo di una nota.",
|
||||
"slash_commands_enabled": "Abilita i comandi slash",
|
||||
"slash_commands_description": "Se abilitato, i comandi di modifica come l'inserimento di interruzioni di riga o intestazioni possono essere attivati digitando `/`."
|
||||
"slash_commands_description": "Se abilitato, i comandi di modifica come l'inserimento di interruzioni di riga o intestazioni possono essere attivati digitando `/`."
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
@@ -381,8 +381,8 @@
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati",
|
||||
"owning_note": "Nota di proprietà:",
|
||||
"you_can_also_open": ", puoi anche aprire il",
|
||||
"owning_note": "Nota di proprietà: ",
|
||||
"you_can_also_open": ", puoi anche aprire il ",
|
||||
"list_of_all_attachments": "Elenco di tutti gli allegati",
|
||||
"attachment_deleted": "Questo allegato è stato eliminato."
|
||||
},
|
||||
@@ -703,7 +703,7 @@
|
||||
"last_attempt": "Ultimo tentativo",
|
||||
"actions": "Azioni",
|
||||
"retry": "Riprova",
|
||||
"partial": "{{ percentuale }}% completato",
|
||||
"partial": "{{ percentage }}% completato",
|
||||
"retry_queued": "Nota in coda per un nuovo tentativo",
|
||||
"retry_failed": "Impossibile mettere in coda la nota per un nuovo tentativo",
|
||||
"max_notes_per_llm_query": "Numero massimo di note per query",
|
||||
@@ -719,12 +719,12 @@
|
||||
"reprocess_index_started": "Ottimizzazione dell'indice di ricerca avviata in background",
|
||||
"reprocess_index_error": "Errore durante la ricostruzione dell'indice di ricerca",
|
||||
"index_rebuild_progress": "Progresso nella ricostruzione dell'indice",
|
||||
"index_rebuilding": "Indice di ottimizzazione ({{percentuale}}%)",
|
||||
"index_rebuilding": "Indice di ottimizzazione ({{percentage}}%)",
|
||||
"index_rebuild_complete": "Ottimizzazione dell'indice completata",
|
||||
"index_rebuild_status_error": "Errore durante il controllo dello stato di ricostruzione dell'indice",
|
||||
"never": "Mai",
|
||||
"processing": "Elaborazione ({{percentuale}}%)",
|
||||
"incomplete": "Incompleto ({{percentuale}}%)",
|
||||
"processing": "Elaborazione ({{percentage}}%)",
|
||||
"incomplete": "Incompleto ({{percentage}}%)",
|
||||
"complete": "Completato (100%)",
|
||||
"refreshing": "Rinfrescante...",
|
||||
"auto_refresh_notice": "Si aggiorna automaticamente ogni {{seconds}} secondi",
|
||||
@@ -761,9 +761,7 @@
|
||||
"indexing_stopped": "Indicizzazione interrotta",
|
||||
"indexing_in_progress": "Indicizzazione in corso...",
|
||||
"last_indexed": "Ultimo indicizzato",
|
||||
"n_notes_queued": "{{ count }} nota in coda per l'indicizzazione",
|
||||
"note_chat": "Nota Chat",
|
||||
"notes_indexed": "{{ count }} nota indicizzata",
|
||||
"sources": "Fonti",
|
||||
"start_indexing": "Avvia l'indicizzazione",
|
||||
"use_advanced_context": "Usa contesto avanzato",
|
||||
@@ -811,7 +809,8 @@
|
||||
"codeImportedAsCode": "Importa i file di codice riconosciuti (ad esempio <code>.json</code>) come note di codice se non è chiaro dai metadati",
|
||||
"replaceUnderscoresWithSpaces": "Sostituisci i trattini bassi con spazi nei nomi delle note importate",
|
||||
"import": "Importa",
|
||||
"failed": "Importazione fallita: {{message}}."
|
||||
"failed": "Importazione fallita: {{message}}.",
|
||||
"importZipRecommendation": "Quando si importa un file ZIP, la gerarchia delle note rifletterà la struttura delle sottodirectory all'interno dell'archivio."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Includi nota",
|
||||
@@ -1478,7 +1477,7 @@
|
||||
},
|
||||
"attachment_list": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati",
|
||||
"owning_note": "Nota di proprietà:",
|
||||
"owning_note": "Nota di proprietà: ",
|
||||
"upload_attachments": "Carica allegati",
|
||||
"no_attachments": "Questa nota non ha allegati."
|
||||
},
|
||||
@@ -1710,7 +1709,7 @@
|
||||
"for_more_info": "per maggiori informazioni.",
|
||||
"protected_session_timeout_label": "Timeout della sessione protetta:",
|
||||
"reset_confirmation": "Reimpostando la password perderai per sempre l'accesso a tutte le tue note protette. Vuoi davvero reimpostare la password?",
|
||||
"reset_success_message": "La password è stata reimpostata. Imposta una nuova password.",
|
||||
"reset_success_message": "La password è stata resettata. Imposta una nuova password",
|
||||
"change_password_heading": "Cambiare la password",
|
||||
"set_password_heading": "Imposta password",
|
||||
"set_password": "Imposta password",
|
||||
@@ -1740,14 +1739,14 @@
|
||||
"recovery_keys_no_key_set": "Nessun codice di ripristino impostato",
|
||||
"recovery_keys_generate": "Genera codici di recupero",
|
||||
"recovery_keys_regenerate": "Rigenera i codici di recupero",
|
||||
"recovery_keys_used": "Utilizzato: {{data}}",
|
||||
"recovery_keys_used": "Utilizzato: {{date}}",
|
||||
"recovery_keys_unused": "Il codice di ripristino {{index}} non è utilizzato",
|
||||
"oauth_title": "OAuth/OpenID",
|
||||
"oauth_description": "OpenID è un metodo standardizzato che ti consente di accedere ai siti web utilizzando un account di un altro servizio, come Google, per verificare la tua identità. L'emittente predefinito è Google, ma puoi cambiarlo con qualsiasi altro provider OpenID. Per ulteriori informazioni, consulta <a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">qui</a>. Segui queste <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">istruzioni</a> per configurare un servizio OpenID tramite Google.",
|
||||
"oauth_description_warning": "Per abilitare OAuth/OpenID, è necessario impostare l'URL di base di OAuth/OpenID, l'ID client e il segreto client nel file config.ini e riavviare l'applicazione. Per impostare le variabili d'ambiente, impostare TRILIUM_OAUTH_BASE_URL, TRILIUM_OAUTH_CLIENT_ID e TRILIUM_OAUTH_CLIENT_SECRET.",
|
||||
"oauth_missing_vars": "Impostazioni mancanti: {{-variabili}}",
|
||||
"oauth_user_account": "Account utente:",
|
||||
"oauth_user_email": "Email utente:",
|
||||
"oauth_missing_vars": "Impostazioni mancanti: {{-variables}}",
|
||||
"oauth_user_account": "Account utente: ",
|
||||
"oauth_user_email": "Email utente: ",
|
||||
"oauth_user_not_logged_in": "Non hai effettuato l'accesso!"
|
||||
},
|
||||
"spellcheck": {
|
||||
@@ -1756,7 +1755,7 @@
|
||||
"enable": "Abilita il controllo ortografico",
|
||||
"language_code_label": "Codice/i della lingua",
|
||||
"language_code_placeholder": "ad esempio \"en-US\", \"de-AT\"",
|
||||
"multiple_languages_info": "È possibile separare più lingue con una virgola, ad esempio \"en-US, de-DE, cs\".",
|
||||
"multiple_languages_info": "È possibile separare più lingue con una virgola, ad esempio \"en-US, de-DE, cs\". ",
|
||||
"available_language_codes_label": "Codici lingua disponibili:",
|
||||
"restart-required": "Le modifiche alle opzioni di controllo ortografico avranno effetto dopo il riavvio dell'applicazione."
|
||||
},
|
||||
@@ -1858,7 +1857,9 @@
|
||||
"window-on-top": "Mantieni la finestra in primo piano"
|
||||
},
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Impossibile trovare typeWidget per il tipo '{{type}}'"
|
||||
"could_not_find_typewidget": "Impossibile trovare typeWidget per il tipo '{{type}}'",
|
||||
"printing": "Stampa in corso...",
|
||||
"printing_pdf": "Esportazione in PDF in corso..."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "scrivi qui il titolo della nota..."
|
||||
@@ -1909,7 +1910,7 @@
|
||||
},
|
||||
"frontend_script_api": {
|
||||
"async_warning": "Stai passando una funzione asincrona a `api.runOnBackend()` che probabilmente non funzionerà come previsto.\\nRendi la funzione sincrona (rimuovendo la parola chiave `async`) oppure usa `api.runAsyncOnBackendWithManualTransactionHandling()`.",
|
||||
"sync_warning": "Stai passando una funzione sincrona a `api.runAsyncOnBackendWithManualTransactionHandling()`, mentre probabilmente dovresti usare `api.runOnBackend()`."
|
||||
"sync_warning": "Stai passando una funzione sincrona a `api.runAsyncOnBackendWithManualTransactionHandling()`, \\nmentre probabilmente dovresti usare `api.runOnBackend()`."
|
||||
},
|
||||
"ws": {
|
||||
"sync-check-failed": "Controllo di sincronizzazione fallito!",
|
||||
@@ -2044,7 +2045,7 @@
|
||||
"slide-overview": "Attiva/disattiva una panoramica delle diapositive"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Albero: {{nome}}",
|
||||
"tree-action-name": "Albero: {{name}}",
|
||||
"export_note_title": "Nota di esportazione",
|
||||
"export_note_description": "Esporta la nota corrente",
|
||||
"show_attachments_title": "Mostra allegati",
|
||||
@@ -2087,4 +2088,4 @@
|
||||
"collections": {
|
||||
"rendering_error": "Impossibile mostrare il contenuto a causa di un errore."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"left_pane_toggle": {
|
||||
"show_panel": "パネルを表示",
|
||||
"hide_panel": "パネルを隠す"
|
||||
"hide_panel": "パネルを非表示"
|
||||
},
|
||||
"move_pane_button": {
|
||||
"move_left": "左に移動",
|
||||
@@ -741,7 +741,7 @@
|
||||
"new-column": "新しい列",
|
||||
"sort-column-by": "\"{{title}}\" で並べ替え",
|
||||
"sort-column-clear": "並べ替えをクリア",
|
||||
"hide-column": "列 \"{{title}}\" を隠す",
|
||||
"hide-column": "列 \"{{title}}\" を非表示",
|
||||
"show-hide-columns": "列を表示/非表示",
|
||||
"row-insert-above": "上に行を挿入",
|
||||
"row-insert-below": "下に行を挿入",
|
||||
@@ -1200,7 +1200,7 @@
|
||||
"collapse-title": "ノートツリーを折りたたむ",
|
||||
"scroll-active-title": "アクティブノートまでスクロール",
|
||||
"tree-settings-title": "ツリーの設定",
|
||||
"hide-archived-notes": "アーカイブノートを隠す",
|
||||
"hide-archived-notes": "アーカイブノートを非表示",
|
||||
"automatically-collapse-notes": "ノートを自動的に折りたたむ",
|
||||
"automatically-collapse-notes-title": "一定期間使用されないと、ツリーを整理するためにノートは折りたたまれます。",
|
||||
"save-changes": "変更を保存して適用",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -989,7 +989,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.",
|
||||
|
||||
@@ -320,7 +320,8 @@
|
||||
"explodeArchivesTooltip": "Если этот флажок установлен, Trilium будет читать файлы <code>.zip</code>, <code>.enex</code> и <code>.opml</code> и создавать заметки из файлов внутри этих архивов. Если флажок не установлен, Trilium будет прикреплять сами архивы к заметке.",
|
||||
"explodeArchives": "Прочитать содержимое архивов <code>.zip</code>, <code>.enex</code> и <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Если этот параметр включен, Trilium попытается уменьшить размер импортируемых изображений путём масштабирования и оптимизации, что может повлиять на воспринимаемое качество изображения. Если этот параметр не установлен, изображения будут импортированы без изменений.</p><p>Это не относится к импорту файлов <code>.zip</code> с метаданными, поскольку предполагается, что эти файлы уже оптимизированы.</p>",
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных"
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных",
|
||||
"importZipRecommendation": "При импорте ZIP файла иерархия заметок будет отражена в структуре папок внутри архива."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Импорт Markdown",
|
||||
@@ -980,7 +981,8 @@
|
||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК"
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||
"new-version-available": "Доступно обновление"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"backlink": "{{count}} ссылки",
|
||||
@@ -1691,7 +1693,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": "Защищенный сеанс запущен.",
|
||||
|
||||
@@ -184,7 +184,8 @@
|
||||
},
|
||||
"import-status": "匯入狀態",
|
||||
"in-progress": "正在匯入:{{progress}}",
|
||||
"successful": "匯入成功。"
|
||||
"successful": "匯入成功。",
|
||||
"importZipRecommendation": "匯入 ZIP 檔案時,筆記層級將反映壓縮檔內的子目錄結構。"
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "內嵌筆記",
|
||||
@@ -988,7 +989,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
||||
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
|
||||
"start_session_button": "開始受保護的作業階段",
|
||||
"started": "已啟動受保護的作業階段。",
|
||||
"wrong_password": "密碼錯誤。",
|
||||
"protecting-finished-successfully": "已成功完成保護操作。",
|
||||
|
||||
@@ -1090,7 +1090,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
|
||||
"start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>",
|
||||
"start_session_button": "Розпочати захищений сеанс",
|
||||
"started": "Захищений сеанс розпочато.",
|
||||
"wrong_password": "Неправильний пароль.",
|
||||
"protecting-finished-successfully": "Захист успішно завершено.",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
"button_add_link": "Thêm liên kết",
|
||||
"help_on_links": "Trợ giúp về các liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
@@ -41,7 +42,13 @@
|
||||
"message": "Đã xảy ra lỗi nghiêm trọng ngăn ứng dụng client khởi động\n\n{{message}}\n\nĐiều này có khả năng bị gây ra bởi một script hoạt động không như mong đợi. Hãy thử khởi động ứng dụng ở chế độ an toàn và giải quyết vấn đề."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Khởi tạo widget thất bại"
|
||||
"title": "Khởi tạo widget thất bại",
|
||||
"message-custom": "Tiện ích tùy chỉnh từ ghi chú với ID \"{{id}}\", tiêu đề \"{{title}}\" không thể khởi tạo vì:\n\n{{message}}",
|
||||
"message-unknown": "Tiện ích chưa biết không thể được khởi tạo vì:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Tải script tùy chọn thất bại",
|
||||
"message": "Script từ ghi chú ID \"{{id}}\", tiêu đề \"{{title}}\" không thể chạy được vì:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
|
||||
11
apps/client/src/types-lib.d.ts
vendored
11
apps/client/src/types-lib.d.ts
vendored
@@ -60,3 +60,14 @@ declare global {
|
||||
windowControlsOverlay?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "preact" {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
webview: {
|
||||
src: string;
|
||||
class: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/client/src/types.d.ts
vendored
8
apps/client/src/types.d.ts
vendored
@@ -119,11 +119,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/client/src/widgets/NoteDetail.css
Normal file
13
apps/client/src/widgets/NoteDetail.css
Normal 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;
|
||||
}
|
||||
324
apps/client/src/widgets/NoteDetail.tsx
Normal file
324
apps/client/src/widgets/NoteDetail.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
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, isElectron, isMobile } from "../services/utils";
|
||||
import toast from "../services/toast.js";
|
||||
import { t } from "../services/i18n";
|
||||
|
||||
/**
|
||||
* The note detail is in charge of rendering the content of a note, by determining its type (e.g. text, code) and using the appropriate view widget.
|
||||
*
|
||||
* Apart from that, it:
|
||||
* - Applies a full-height style depending on the content type (e.g. canvas notes).
|
||||
* - Focuses the content when switching tabs.
|
||||
* - Caches the note type elements based on what the user has accessed, in order to quickly load it again.
|
||||
* - Fixes the tree for launch bar configurations on mobile.
|
||||
* - Provides scripting events such as obtaining the active note detail widget, or note type widget.
|
||||
* - Printing and exporting to PDF.
|
||||
*/
|
||||
export default function NoteDetail() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { note, type, mime, noteContext, parentComponent } = useNoteInfo();
|
||||
const { ntxId, viewScope } = noteContext ?? {};
|
||||
const isFullHeight = checkFullHeight(noteContext, type);
|
||||
const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
|
||||
const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
ntxId,
|
||||
parentComponent,
|
||||
noteContext
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!type) return;
|
||||
|
||||
if (!noteTypesToRender.current[type]) {
|
||||
getCorrespondingWidget(type).then((el) => {
|
||||
if (!el) return;
|
||||
noteTypesToRender.current[type] = el;
|
||||
setActiveNoteType(type);
|
||||
});
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
if (!note) return;
|
||||
|
||||
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
|
||||
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
|
||||
// times if the same note is open in several tabs.
|
||||
|
||||
if (note.noteId && loadResults.isNoteContentReloaded(note.noteId, parentComponent.componentId)) {
|
||||
// probably incorrect event
|
||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||
// FIXME: create a separate event to force hierarchical refresh
|
||||
|
||||
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
|
||||
// to avoid the problem in #3365
|
||||
parentComponent.handleEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
} else if (note.noteId
|
||||
&& loadResults.isNoteReloaded(note.noteId, parentComponent.componentId)
|
||||
&& (type !== (await getWidgetType(note, noteContext)) || mime !== note?.mime)) {
|
||||
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
|
||||
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
} else {
|
||||
const attrs = loadResults.getAttributeRows();
|
||||
|
||||
const label = attrs.find(
|
||||
(attr) =>
|
||||
attr.type === "label" &&
|
||||
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
|
||||
attributes.isAffecting(attr, note)
|
||||
);
|
||||
|
||||
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"]
|
||||
.includes(attr.name ?? "") && attributes.isAffecting(attr, note));
|
||||
|
||||
if (note.noteId && (label || relation)) {
|
||||
// probably incorrect event
|
||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically focus the editor.
|
||||
useTriliumEvent("activeNoteChanged", () => {
|
||||
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
|
||||
if (!document.activeElement?.classList.contains("fancytree-title")) {
|
||||
parentComponent.triggerCommand("focusOnDetail", { ntxId });
|
||||
}
|
||||
});
|
||||
|
||||
// Fixed tree for launch bar config on mobile.
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const hasFixedTree = noteContext?.hoistedNoteId === "_lbMobileRoot";
|
||||
document.body.classList.toggle("force-fixed-tree", hasFixedTree);
|
||||
}, [ note ]);
|
||||
|
||||
// Handle toast notifications.
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
const listener = () => {
|
||||
toast.closePersistent("printing");
|
||||
};
|
||||
ipcRenderer.on("print-done", listener);
|
||||
return () => ipcRenderer.off("print-done", listener);
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
|
||||
if (!noteContext?.isActive()) return;
|
||||
callback(parentComponent);
|
||||
});
|
||||
|
||||
useTriliumEvent("executeWithTypeWidget", ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId || !activeNoteType || !containerRef.current) return;
|
||||
|
||||
const classNameToSearch = TYPE_MAPPINGS[activeNoteType].className;
|
||||
const componentEl = containerRef.current.querySelector<HTMLElement>(`.${classNameToSearch}`);
|
||||
if (!componentEl) return;
|
||||
|
||||
const component = glob.getComponentByEl(componentEl);
|
||||
resolve(component);
|
||||
});
|
||||
|
||||
useTriliumEvent("printActiveNote", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
if (isElectron()) {
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("print-note", {
|
||||
notePath: noteContext.notePath
|
||||
});
|
||||
} else {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `?print#${noteContext.notePath}`;
|
||||
iframe.className = "print-iframe";
|
||||
document.body.appendChild(iframe);
|
||||
iframe.onload = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
toast.closePersistent("printing");
|
||||
document.body.removeChild(iframe);
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.contentWindow.addEventListener("note-ready", () => {
|
||||
toast.closePersistent("printing");
|
||||
iframe.contentWindow?.print();
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useTriliumEvent("exportAsPdf", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing_pdf"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: note.title,
|
||||
notePath: noteContext.notePath,
|
||||
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: note.hasAttribute("label", "printLandscape")
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||
>
|
||||
{Object.entries(noteTypesToRender.current).map(([ 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,9 @@ export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
}
|
||||
});
|
||||
|
||||
this.$tooltip = this.$widget.find(".tooltip-trigger").attr("title", this.title);
|
||||
this.tooltip = new Tooltip(this.$tooltip[0], {
|
||||
this.$widget.attr("title", this.title);
|
||||
this.tooltip = Tooltip.getOrCreateInstance(this.$widget[0], {
|
||||
trigger: "hover",
|
||||
placement: handleRightToLeftPlacement(this.settings.titlePlacement),
|
||||
fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ]
|
||||
});
|
||||
@@ -56,9 +57,7 @@ export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
this.$widget
|
||||
.find(".right-dropdown-button")
|
||||
.addClass(this.iconClass)
|
||||
.on("click", () => this.tooltip.hide())
|
||||
.on("mouseenter", () => this.tooltip.show())
|
||||
.on("mouseleave", () => this.tooltip.hide());
|
||||
.on("click", () => this.tooltip.hide());
|
||||
|
||||
this.$widget.on("show.bs.dropdown", async () => {
|
||||
await this.dropdownShown();
|
||||
|
||||
@@ -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";
|
||||
@@ -66,6 +66,7 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
|
||||
de: () => import("@fullcalendar/core/locales/de"),
|
||||
es: () => import("@fullcalendar/core/locales/es"),
|
||||
fr: () => import("@fullcalendar/core/locales/fr"),
|
||||
it: () => import("@fullcalendar/core/locales/it"),
|
||||
cn: () => import("@fullcalendar/core/locales/zh-cn"),
|
||||
tw: () => import("@fullcalendar/core/locales/zh-tw"),
|
||||
ro: () => import("@fullcalendar/core/locales/ro"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -141,7 +141,11 @@ function NoteContent({ note, trim, noChildrenList, highlightedTokens }: { note:
|
||||
})
|
||||
.then(({ $renderedContent, type }) => {
|
||||
if (!contentRef.current) return;
|
||||
contentRef.current.replaceChildren(...$renderedContent);
|
||||
if ($renderedContent[0].innerHTML) {
|
||||
contentRef.current.replaceChildren(...$renderedContent);
|
||||
} else {
|
||||
contentRef.current.replaceChildren();
|
||||
}
|
||||
contentRef.current.classList.add(`type-${type}`);
|
||||
highlightSearch(contentRef.current);
|
||||
})
|
||||
|
||||
@@ -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") && (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,12 @@ const categories: Category[] = [
|
||||
];
|
||||
|
||||
const icons: Icon[] = [
|
||||
{
|
||||
name: "empty",
|
||||
slug: "empty",
|
||||
category_id: 113,
|
||||
type_of_icon: "REGULAR"
|
||||
},
|
||||
{
|
||||
name: "child",
|
||||
slug: "child-regular",
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import server from "../services/server.js";
|
||||
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
|
||||
import EmptyTypeWidget from "./type_widgets/empty.js";
|
||||
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
|
||||
import EditableCodeTypeWidget from "./type_widgets/editable_code.js";
|
||||
import FileTypeWidget from "./type_widgets/file.js";
|
||||
import ImageTypeWidget from "./type_widgets/image.js";
|
||||
import RenderTypeWidget from "./type_widgets/render.js";
|
||||
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
|
||||
import CanvasTypeWidget from "./type_widgets/canvas.js";
|
||||
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
|
||||
import BookTypeWidget from "./type_widgets/book.js";
|
||||
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
|
||||
import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js";
|
||||
import NoneTypeWidget from "./type_widgets/none.js";
|
||||
import NoteMapTypeWidget from "./type_widgets/note_map.js";
|
||||
import WebViewTypeWidget from "./type_widgets/web_view.js";
|
||||
import DocTypeWidget from "./type_widgets/doc.js";
|
||||
import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
||||
import utils, { isElectron } from "../services/utils.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type TypeWidget from "./type_widgets/type_widget.js";
|
||||
import { MermaidTypeWidget } from "./type_widgets/mermaid.js";
|
||||
import AiChatTypeWidget from "./type_widgets/ai_chat.js";
|
||||
import toast from "../services/toast.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-detail">
|
||||
<style>
|
||||
.note-detail {
|
||||
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();
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
ipcRenderer.on("print-done", () => {
|
||||
toast.closePersistent("printing");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.type = await this.getWidgetType();
|
||||
this.mime = this.note?.mime;
|
||||
|
||||
if (!(this.type in this.typeWidgets)) {
|
||||
const clazz = typeWidgetClasses[this.type];
|
||||
|
||||
if (!clazz) {
|
||||
throw new Error(`Cannot find type widget for type '${this.type}'`);
|
||||
}
|
||||
|
||||
const typeWidget = (this.typeWidgets[this.type] = new clazz());
|
||||
typeWidget.spacedUpdate = this.spacedUpdate;
|
||||
typeWidget.setParent(this);
|
||||
|
||||
if (this.noteContext) {
|
||||
typeWidget.setNoteContextEvent({ noteContext: this.noteContext });
|
||||
}
|
||||
const $renderedWidget = typeWidget.render();
|
||||
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
|
||||
|
||||
this.$widget.append($renderedWidget);
|
||||
|
||||
if (this.noteContext) {
|
||||
await typeWidget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
||||
}
|
||||
|
||||
// this is happening in update(), so note has been already set, and we need to reflect this
|
||||
if (this.noteContext) {
|
||||
await typeWidget.handleEvent("noteSwitched", {
|
||||
noteContext: this.noteContext,
|
||||
notePath: this.noteContext.notePath
|
||||
});
|
||||
}
|
||||
|
||||
this.child(typeWidget);
|
||||
}
|
||||
|
||||
this.checkFullHeight();
|
||||
|
||||
if (utils.isMobile()) {
|
||||
const hasFixedTree = this.noteContext?.hoistedNoteId === "_lbMobileRoot";
|
||||
$("body").toggleClass("force-fixed-tree", hasFixedTree);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sets full height of container that contains note content for a subset of note-types
|
||||
*/
|
||||
checkFullHeight() {
|
||||
// https://github.com/zadam/trilium/issues/2522
|
||||
const isBackendNote = this.noteContext?.noteId === "_backendLog";
|
||||
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
|
||||
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file", "aiChat"].includes(this.type ?? "");
|
||||
const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|
||||
|| this.noteContext?.viewScope?.viewMode === "attachments"
|
||||
|| isBackendNote;
|
||||
|
||||
this.$widget.toggleClass("full-height", isFullHeight);
|
||||
}
|
||||
|
||||
getTypeWidget() {
|
||||
if (!this.type || !this.typeWidgets[this.type]) {
|
||||
throw new Error(t(`note_detail.could_not_find_typewidget`, { type: this.type }));
|
||||
}
|
||||
|
||||
return this.typeWidgets[this.type];
|
||||
}
|
||||
|
||||
async getWidgetType(): Promise<ExtendedNoteType> {
|
||||
const note = this.note;
|
||||
if (!note) {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
const type = note.type;
|
||||
let resultingType: ExtendedNoteType;
|
||||
const viewScope = this.noteContext?.viewScope;
|
||||
|
||||
if (viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (viewScope && viewScope.viewMode === "attachments") {
|
||||
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
|
||||
resultingType = "readOnlyText";
|
||||
} else if ((type === "code" || type === "mermaid") && (await this.noteContext?.isReadOnly())) {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (type === "text") {
|
||||
resultingType = "editableText";
|
||||
} else if (type === "code") {
|
||||
resultingType = "editableCode";
|
||||
} else if (type === "launcher") {
|
||||
resultingType = "doc";
|
||||
} else {
|
||||
resultingType = type;
|
||||
}
|
||||
|
||||
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
resultingType = "protectedSession";
|
||||
}
|
||||
|
||||
return resultingType;
|
||||
}
|
||||
|
||||
async focusOnDetailEvent({ ntxId }: EventData<"focusOnDetail">) {
|
||||
if (this.noteContext?.ntxId !== ntxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
const widget = this.getTypeWidget();
|
||||
await widget.initialized;
|
||||
widget.focus();
|
||||
}
|
||||
|
||||
async scrollToEndEvent({ ntxId }: EventData<"scrollToEnd">) {
|
||||
if (this.noteContext?.ntxId !== ntxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
const widget = this.getTypeWidget();
|
||||
await widget.initialized;
|
||||
|
||||
if (widget.scrollToEnd) {
|
||||
widget.scrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) {
|
||||
if (this.isNoteContext(noteContext.ntxId)) {
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) {
|
||||
if (this.isNoteContext(ntxIds)) {
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
|
||||
if (this.isNoteContext(params.ntxId)) {
|
||||
// make sure that script is saved before running it #4028
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
|
||||
return await this.parent?.triggerCommand("runActiveNote", params);
|
||||
}
|
||||
|
||||
async printActiveNoteEvent() {
|
||||
if (!this.noteContext?.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
if (isElectron()) {
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
ipcRenderer.send("print-note", {
|
||||
notePath: this.notePath
|
||||
});
|
||||
} else {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `?print#${this.notePath}`;
|
||||
iframe.className = "print-iframe";
|
||||
document.body.appendChild(iframe);
|
||||
iframe.onload = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
toast.closePersistent("printing");
|
||||
document.body.removeChild(iframe);
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.contentWindow.addEventListener("note-ready", () => {
|
||||
toast.closePersistent("printing");
|
||||
iframe.contentWindow?.print();
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async exportAsPdfEvent() {
|
||||
if (!this.noteContext?.isActive() || !this.note || !this.notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing_pdf"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: this.note.title,
|
||||
notePath: this.notePath,
|
||||
pageSize: this.note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: this.note.hasAttribute("label", "printLandscape")
|
||||
});
|
||||
}
|
||||
|
||||
hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
|
||||
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
|
||||
// times if the same note is open in several tabs.
|
||||
|
||||
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId, this.componentId)) {
|
||||
// probably incorrect event
|
||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||
// FIXME: create a separate event to force hierarchical refresh
|
||||
|
||||
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
|
||||
// to avoid the problem in #3365
|
||||
this.handleEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
||||
} else if (this.noteId && loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== (await this.getWidgetType()) || this.mime !== this.note?.mime)) {
|
||||
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
|
||||
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
||||
} else {
|
||||
const attrs = loadResults.getAttributeRows();
|
||||
|
||||
const label = attrs.find(
|
||||
(attr) =>
|
||||
attr.type === "label" &&
|
||||
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
|
||||
attributeService.isAffecting(attr, this.note)
|
||||
);
|
||||
|
||||
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note));
|
||||
|
||||
if (this.noteId && (label || relation)) {
|
||||
// probably incorrect event
|
||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeUnloadEvent() {
|
||||
return this.spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||
}
|
||||
|
||||
readOnlyTemporarilyDisabledEvent({ noteContext }: EventData<"readOnlyTemporarilyDisabled">) {
|
||||
if (this.isNoteContext(noteContext.ntxId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async executeInActiveNoteDetailWidgetEvent({ callback }: EventData<"executeInActiveNoteDetailWidget">) {
|
||||
if (!this.isActiveNoteContext()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialized;
|
||||
|
||||
callback(this);
|
||||
}
|
||||
|
||||
async cutIntoNoteCommand() {
|
||||
const note = appContext.tabManager.getActiveContextNote();
|
||||
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
// without await as this otherwise causes deadlock through component mutex
|
||||
const parentNotePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (this.noteContext && parentNotePath) {
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
isProtected: note.isProtected,
|
||||
saveSelection: true,
|
||||
textEditor: await this.noteContext.getTextEditor()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// used by cutToNote in CKEditor build
|
||||
async saveNoteDetailNowCommand() {
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
|
||||
renderActiveNoteEvent() {
|
||||
if (this.noteContext?.isActive()) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async executeWithTypeWidgetEvent({ resolve, ntxId }: EventData<"executeWithTypeWidget">) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialized;
|
||||
|
||||
await this.getWidgetType();
|
||||
|
||||
resolve(this.getTypeWidget());
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,16 @@
|
||||
|
||||
.note-icon-widget .icon-list span:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
border: 1px dashed var(--muted-text-color);
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
57
apps/client/src/widgets/note_map/NoteMap.css
Normal file
57
apps/client/src/widgets/note_map/NoteMap.css
Normal 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 */
|
||||
174
apps/client/src/widgets/note_map/NoteMap.tsx
Normal file
174
apps/client/src/widgets/note_map/NoteMap.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
120
apps/client/src/widgets/note_map/data.ts
Normal file
120
apps/client/src/widgets/note_map/data.ts
Normal 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);
|
||||
}
|
||||
282
apps/client/src/widgets/note_map/rendering.ts
Normal file
282
apps/client/src/widgets/note_map/rendering.ts
Normal 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;
|
||||
}
|
||||
33
apps/client/src/widgets/note_map/utils.ts
Normal file
33
apps/client/src/widgets/note_map/utils.ts
Normal 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";
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
142
apps/client/src/widgets/note_types.tsx
Normal file
142
apps/client/src/widgets/note_types.tsx
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
{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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
20
apps/client/src/widgets/react/Slider.tsx
Normal file
20
apps/client/src/widgets/react/Slider.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNoteContext, useTriliumOption } from "../react/hooks";
|
||||
import { useTriliumOption } from "../react/hooks";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
|
||||
/**
|
||||
* Handles the editing toolbar when the CKEditor is in decoupled mode.
|
||||
@@ -6,19 +7,13 @@ import { useNoteContext, useTriliumOption } from "../react/hooks";
|
||||
* This toolbar is only enabled if the user has selected the classic CKEditor.
|
||||
*
|
||||
* The ribbon item is active by default for text notes, as long as they are not in read-only mode.
|
||||
*
|
||||
*
|
||||
* ! The toolbar is not only used in the ribbon, but also in the quick edit feature.
|
||||
*/
|
||||
export default function FormattingToolbar({ hidden }: { hidden?: boolean }) {
|
||||
export default function FormattingToolbar({ hidden }: TabContext) {
|
||||
const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType");
|
||||
|
||||
return (textNoteEditorType === "ckeditor-classic" &&
|
||||
<div className={`classic-toolbar-widget ${hidden ? "hidden-ext" : ""}`} />
|
||||
)
|
||||
};
|
||||
|
||||
export function PopupEditorFormattingToolbar() {
|
||||
// TODO: Integrate this directly once we migrate away from class components.
|
||||
const { note } = useNoteContext();
|
||||
return <FormattingToolbar hidden={note?.type !== "text"} />;
|
||||
}
|
||||
@@ -46,11 +46,11 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(note.type);
|
||||
const isInOptions = note.noteId.startsWith("_options");
|
||||
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
|
||||
const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation");
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
|
||||
const isSearchOrBook = ["search", "book"].includes(note.type);
|
||||
|
||||
return (
|
||||
@@ -69,10 +69,10 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
<FormDropdownDivider />
|
||||
|
||||
<CommandItem icon="bx bx-import" text={t("note_actions.import_files")}
|
||||
disabled={isInOptions || note.type === "search"}
|
||||
disabled={isInOptionsOrHelp || note.type === "search"}
|
||||
command={() => parentComponent?.triggerCommand("showImportDialog", { noteId: note.noteId })} />
|
||||
<CommandItem icon="bx bx-export" text={t("note_actions.export_note")}
|
||||
disabled={isInOptions || note.noteId === "_backendLog"}
|
||||
disabled={isInOptionsOrHelp || note.noteId === "_backendLog"}
|
||||
command={() => noteContext?.notePath && parentComponent?.triggerCommand("showExportDialog", {
|
||||
notePath: noteContext.notePath,
|
||||
defaultType: "single"
|
||||
@@ -84,14 +84,14 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptions} text={t("note_actions.save_revision")} />
|
||||
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
|
||||
<CommandItem icon="bx bx-trash destructive-action-icon" text={t("note_actions.delete_note")} destructive
|
||||
disabled={isInOptions}
|
||||
disabled={isInOptionsOrHelp}
|
||||
command={() => branches.deleteNotes([note.getParentBranches()[0].branchId])}
|
||||
/>
|
||||
<FormDropdownDivider />
|
||||
|
||||
<CommandItem command="showAttachments" icon="bx bx-paperclip" disabled={isInOptions} text={t("note_actions.note_attachments")} />
|
||||
<CommandItem command="showAttachments" icon="bx bx-paperclip" disabled={isInOptionsOrHelp} text={t("note_actions.note_attachments")} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks";
|
||||
import "./style.css";
|
||||
import { VNode } from "preact";
|
||||
import BasicPropertiesTab from "./BasicPropertiesTab";
|
||||
import FormattingToolbar from "./FormattingToolbar";
|
||||
|
||||
import { numberObjectsInPlace } from "../../services/utils";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
import options from "../../services/options";
|
||||
import { EventNames } from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import ScriptTab from "./ScriptTab";
|
||||
import EditedNotesTab from "./EditedNotesTab";
|
||||
import NotePropertiesTab from "./NotePropertiesTab";
|
||||
import NoteInfoTab from "./NoteInfoTab";
|
||||
import SimilarNotesTab from "./SimilarNotesTab";
|
||||
import FilePropertiesTab from "./FilePropertiesTab";
|
||||
import ImagePropertiesTab from "./ImagePropertiesTab";
|
||||
import NotePathsTab from "./NotePathsTab";
|
||||
import NoteMapTab from "./NoteMapTab";
|
||||
import OwnedAttributesTab from "./OwnedAttributesTab";
|
||||
import InheritedAttributesTab from "./InheritedAttributesTab";
|
||||
import CollectionPropertiesTab from "./CollectionPropertiesTab";
|
||||
import SearchDefinitionTab from "./SearchDefinitionTab";
|
||||
import NoteActions from "./NoteActions";
|
||||
import { KeyboardActionNames } from "@triliumnext/commons";
|
||||
import { RIBBON_TAB_DEFINITIONS } from "./RibbonDefinition";
|
||||
import { TabConfiguration, TitleContext } from "./ribbon-interface";
|
||||
|
||||
interface TitleContext {
|
||||
note: FNote | null | undefined;
|
||||
}
|
||||
|
||||
interface TabConfiguration {
|
||||
title: string | ((context: TitleContext) => string);
|
||||
icon: string;
|
||||
content: (context: TabContext) => VNode | false;
|
||||
show: boolean | ((context: TitleContext) => boolean | null | undefined);
|
||||
toggleCommand?: KeyboardActionNames;
|
||||
activate?: boolean | ((context: TitleContext) => boolean);
|
||||
/**
|
||||
* By default the tab content will not be rendered unless the tab is active (i.e. selected by the user). Setting to `true` will ensure that the tab is rendered even when inactive, for cases where the tab needs to be accessible at all times (e.g. for the detached editor toolbar) or if event handling is needed.
|
||||
*/
|
||||
stayInDom?: boolean;
|
||||
}
|
||||
|
||||
const TAB_CONFIGURATION = numberObjectsInPlace<TabConfiguration>([
|
||||
{
|
||||
title: t("classic_editor_toolbar.title"),
|
||||
icon: "bx bx-text",
|
||||
show: ({ note }) => note?.type === "text" && options.get("textNoteEditorType") === "ckeditor-classic",
|
||||
toggleCommand: "toggleRibbonTabClassicEditor",
|
||||
content: FormattingToolbar,
|
||||
activate: true,
|
||||
stayInDom: true
|
||||
},
|
||||
{
|
||||
title: ({ note }) => note?.isTriliumSqlite() ? t("script_executor.query") : t("script_executor.script"),
|
||||
icon: "bx bx-play",
|
||||
content: ScriptTab,
|
||||
activate: true,
|
||||
show: ({ note }) => note &&
|
||||
(note.isTriliumScript() || note.isTriliumSqlite()) &&
|
||||
(note.hasLabel("executeDescription") || note.hasLabel("executeButton"))
|
||||
},
|
||||
{
|
||||
title: t("search_definition.search_parameters"),
|
||||
icon: "bx bx-search",
|
||||
content: SearchDefinitionTab,
|
||||
activate: true,
|
||||
show: ({ note }) => note?.type === "search"
|
||||
},
|
||||
{
|
||||
title: t("edited_notes.title"),
|
||||
icon: "bx bx-calendar-edit",
|
||||
content: EditedNotesTab,
|
||||
show: ({ note }) => note?.hasOwnedLabel("dateNote"),
|
||||
activate: ({ note }) => (note?.getPromotedDefinitionAttributes().length === 0 || !options.is("promotedAttributesOpenInRibbon")) && options.is("editedNotesOpenInRibbon")
|
||||
},
|
||||
{
|
||||
title: t("book_properties.book_properties"),
|
||||
icon: "bx bx-book",
|
||||
content: CollectionPropertiesTab,
|
||||
show: ({ note }) => note?.type === "book" || note?.type === "search",
|
||||
toggleCommand: "toggleRibbonTabBookProperties"
|
||||
},
|
||||
{
|
||||
title: t("note_properties.info"),
|
||||
icon: "bx bx-info-square",
|
||||
content: NotePropertiesTab,
|
||||
show: ({ note }) => !!note?.getLabelValue("pageUrl"),
|
||||
activate: true
|
||||
},
|
||||
{
|
||||
title: t("file_properties.title"),
|
||||
icon: "bx bx-file",
|
||||
content: FilePropertiesTab,
|
||||
show: ({ note }) => note?.type === "file",
|
||||
toggleCommand: "toggleRibbonTabFileProperties",
|
||||
activate: ({ note }) => note?.mime !== "application/pdf"
|
||||
},
|
||||
{
|
||||
title: t("image_properties.title"),
|
||||
icon: "bx bx-image",
|
||||
content: ImagePropertiesTab,
|
||||
show: ({ note }) => note?.type === "image",
|
||||
toggleCommand: "toggleRibbonTabImageProperties",
|
||||
activate: true,
|
||||
},
|
||||
{
|
||||
// BasicProperties
|
||||
title: t("basic_properties.basic_properties"),
|
||||
icon: "bx bx-slider",
|
||||
content: BasicPropertiesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabBasicProperties"
|
||||
},
|
||||
{
|
||||
title: t("owned_attribute_list.owned_attributes"),
|
||||
icon: "bx bx-list-check",
|
||||
content: OwnedAttributesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabOwnedAttributes",
|
||||
stayInDom: true
|
||||
},
|
||||
{
|
||||
title: t("inherited_attribute_list.title"),
|
||||
icon: "bx bx-list-plus",
|
||||
content: InheritedAttributesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabInheritedAttributes"
|
||||
},
|
||||
{
|
||||
title: t("note_paths.title"),
|
||||
icon: "bx bx-collection",
|
||||
content: NotePathsTab,
|
||||
show: true,
|
||||
toggleCommand: "toggleRibbonTabNotePaths"
|
||||
},
|
||||
{
|
||||
title: t("note_map.title"),
|
||||
icon: "bx bxs-network-chart",
|
||||
content: NoteMapTab,
|
||||
show: true,
|
||||
toggleCommand: "toggleRibbonTabNoteMap"
|
||||
},
|
||||
{
|
||||
title: t("similar_notes.title"),
|
||||
icon: "bx bx-bar-chart",
|
||||
show: ({ note }) => note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
|
||||
content: SimilarNotesTab,
|
||||
toggleCommand: "toggleRibbonTabSimilarNotes"
|
||||
},
|
||||
{
|
||||
title: t("note_info_widget.title"),
|
||||
icon: "bx bx-info-circle",
|
||||
show: ({ note }) => !!note,
|
||||
content: NoteInfoTab,
|
||||
toggleCommand: "toggleRibbonTabNoteInfo"
|
||||
}
|
||||
]);
|
||||
const TAB_CONFIGURATION = numberObjectsInPlace<TabConfiguration>(RIBBON_TAB_DEFINITIONS);
|
||||
|
||||
export default function Ribbon() {
|
||||
const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId } = useNoteContext();
|
||||
|
||||
134
apps/client/src/widgets/ribbon/RibbonDefinition.ts
Normal file
134
apps/client/src/widgets/ribbon/RibbonDefinition.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import ScriptTab from "./ScriptTab";
|
||||
import EditedNotesTab from "./EditedNotesTab";
|
||||
import NotePropertiesTab from "./NotePropertiesTab";
|
||||
import NoteInfoTab from "./NoteInfoTab";
|
||||
import SimilarNotesTab from "./SimilarNotesTab";
|
||||
import FilePropertiesTab from "./FilePropertiesTab";
|
||||
import ImagePropertiesTab from "./ImagePropertiesTab";
|
||||
import NotePathsTab from "./NotePathsTab";
|
||||
import NoteMapTab from "./NoteMapTab";
|
||||
import OwnedAttributesTab from "./OwnedAttributesTab";
|
||||
import InheritedAttributesTab from "./InheritedAttributesTab";
|
||||
import CollectionPropertiesTab from "./CollectionPropertiesTab";
|
||||
import SearchDefinitionTab from "./SearchDefinitionTab";
|
||||
import BasicPropertiesTab from "./BasicPropertiesTab";
|
||||
import FormattingToolbar from "./FormattingToolbar";
|
||||
import options from "../../services/options";
|
||||
import { t } from "../../services/i18n";
|
||||
import { TabConfiguration } from "./ribbon-interface";
|
||||
|
||||
export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
|
||||
{
|
||||
title: t("classic_editor_toolbar.title"),
|
||||
icon: "bx bx-text",
|
||||
show: ({ note }) => note?.type === "text" && options.get("textNoteEditorType") === "ckeditor-classic",
|
||||
toggleCommand: "toggleRibbonTabClassicEditor",
|
||||
content: FormattingToolbar,
|
||||
activate: true,
|
||||
stayInDom: true
|
||||
},
|
||||
{
|
||||
title: ({ note }) => note?.isTriliumSqlite() ? t("script_executor.query") : t("script_executor.script"),
|
||||
icon: "bx bx-play",
|
||||
content: ScriptTab,
|
||||
activate: true,
|
||||
show: ({ note }) => note &&
|
||||
(note.isTriliumScript() || note.isTriliumSqlite()) &&
|
||||
(note.hasLabel("executeDescription") || note.hasLabel("executeButton"))
|
||||
},
|
||||
{
|
||||
title: t("search_definition.search_parameters"),
|
||||
icon: "bx bx-search",
|
||||
content: SearchDefinitionTab,
|
||||
activate: true,
|
||||
show: ({ note }) => note?.type === "search"
|
||||
},
|
||||
{
|
||||
title: t("edited_notes.title"),
|
||||
icon: "bx bx-calendar-edit",
|
||||
content: EditedNotesTab,
|
||||
show: ({ note }) => note?.hasOwnedLabel("dateNote"),
|
||||
activate: ({ note }) => (note?.getPromotedDefinitionAttributes().length === 0 || !options.is("promotedAttributesOpenInRibbon")) && options.is("editedNotesOpenInRibbon")
|
||||
},
|
||||
{
|
||||
title: t("book_properties.book_properties"),
|
||||
icon: "bx bx-book",
|
||||
content: CollectionPropertiesTab,
|
||||
show: ({ note }) => note?.type === "book" || note?.type === "search",
|
||||
toggleCommand: "toggleRibbonTabBookProperties"
|
||||
},
|
||||
{
|
||||
title: t("note_properties.info"),
|
||||
icon: "bx bx-info-square",
|
||||
content: NotePropertiesTab,
|
||||
show: ({ note }) => !!note?.getLabelValue("pageUrl"),
|
||||
activate: true
|
||||
},
|
||||
{
|
||||
title: t("file_properties.title"),
|
||||
icon: "bx bx-file",
|
||||
content: FilePropertiesTab,
|
||||
show: ({ note }) => note?.type === "file",
|
||||
toggleCommand: "toggleRibbonTabFileProperties",
|
||||
activate: ({ note }) => note?.mime !== "application/pdf"
|
||||
},
|
||||
{
|
||||
title: t("image_properties.title"),
|
||||
icon: "bx bx-image",
|
||||
content: ImagePropertiesTab,
|
||||
show: ({ note }) => note?.type === "image",
|
||||
toggleCommand: "toggleRibbonTabImageProperties",
|
||||
activate: true,
|
||||
},
|
||||
{
|
||||
// BasicProperties
|
||||
title: t("basic_properties.basic_properties"),
|
||||
icon: "bx bx-slider",
|
||||
content: BasicPropertiesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabBasicProperties"
|
||||
},
|
||||
{
|
||||
title: t("owned_attribute_list.owned_attributes"),
|
||||
icon: "bx bx-list-check",
|
||||
content: OwnedAttributesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabOwnedAttributes",
|
||||
stayInDom: true
|
||||
},
|
||||
{
|
||||
title: t("inherited_attribute_list.title"),
|
||||
icon: "bx bx-list-plus",
|
||||
content: InheritedAttributesTab,
|
||||
show: ({note}) => !note?.isLaunchBarConfig(),
|
||||
toggleCommand: "toggleRibbonTabInheritedAttributes"
|
||||
},
|
||||
{
|
||||
title: t("note_paths.title"),
|
||||
icon: "bx bx-collection",
|
||||
content: NotePathsTab,
|
||||
show: true,
|
||||
toggleCommand: "toggleRibbonTabNotePaths"
|
||||
},
|
||||
{
|
||||
title: t("note_map.title"),
|
||||
icon: "bx bxs-network-chart",
|
||||
content: NoteMapTab,
|
||||
show: true,
|
||||
toggleCommand: "toggleRibbonTabNoteMap"
|
||||
},
|
||||
{
|
||||
title: t("similar_notes.title"),
|
||||
icon: "bx bx-bar-chart",
|
||||
show: ({ note }) => note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
|
||||
content: SimilarNotesTab,
|
||||
toggleCommand: "toggleRibbonTabSimilarNotes"
|
||||
},
|
||||
{
|
||||
title: t("note_info_widget.title"),
|
||||
icon: "bx bx-info-circle",
|
||||
show: ({ note }) => !!note,
|
||||
content: NoteInfoTab,
|
||||
toggleCommand: "toggleRibbonTabNoteInfo"
|
||||
}
|
||||
];
|
||||
@@ -115,7 +115,7 @@ function SearchOption({ note, title, titleIcon, children, help, attributeName, a
|
||||
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<tr className={attributeName}>
|
||||
<td className="title-column">
|
||||
{titleIcon && <><Icon icon={titleIcon} />{" "}</>}
|
||||
{title}
|
||||
|
||||
174
apps/client/src/widgets/ribbon/SearchDefinitionTab.css
Normal file
174
apps/client/src/widgets/ribbon/SearchDefinitionTab.css
Normal file
@@ -0,0 +1,174 @@
|
||||
.search-setting-table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 7px;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 10px;
|
||||
}
|
||||
|
||||
.search-setting-table div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .title-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .dropdown {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .dropdown-menu {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.attribute-list hr {
|
||||
height: 1px;
|
||||
border-color: var(--main-border-color);
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-definition-widget input:invalid {
|
||||
border: 3px solid red;
|
||||
}
|
||||
|
||||
.add-search-option button {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.search-actions-container {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
body.mobile .search-definition-widget {
|
||||
contain: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
.search-setting-table {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.search-setting-table tr {
|
||||
padding: 0.5em 0;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.search-setting-table tr,
|
||||
.search-setting-table td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-setting-table tbody {
|
||||
display: block;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.search-setting-table tbody:first-of-type {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search-setting-table .add-search-option {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-setting-table .add-search-option button {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.search-options tr,
|
||||
.action-options tr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-options tr > td > div {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em 0;
|
||||
}
|
||||
|
||||
.action-options input {
|
||||
max-width: 75vw;
|
||||
}
|
||||
|
||||
.search-setting-table .title-column {
|
||||
width: unset;
|
||||
margin-right: 0.5em;
|
||||
min-width: 30%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column {
|
||||
flex-grow: 1;
|
||||
justify-content: end;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .bx-help-circle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-setting-table tr.orderBy td:nth-of-type(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.search-setting-table tr.searchString td:nth-of-type(2) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.search-setting-table tr.searchString .button-column {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.search-setting-table tr.ancestor > td > div {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.search-actions tr {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.search-actions-container {
|
||||
align-items: center;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.search-result-widget,
|
||||
.note-list.list-view,
|
||||
.note-list-wrapper {
|
||||
overflow: unset;
|
||||
height: unset !important;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import bulk_action, { ACTION_GROUPS } from "../../services/bulk_action";
|
||||
import { FormListHeader, FormListItem } from "../react/FormList";
|
||||
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
|
||||
import { getErrorMessage } from "../../services/utils";
|
||||
import "./SearchDefinitionTab.css";
|
||||
|
||||
export default function SearchDefinitionTab({ note, ntxId }: TabContext) {
|
||||
export default function SearchDefinitionTab({ note, ntxId, hidden }: TabContext) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>();
|
||||
const [ error, setError ] = useState<{ message: string }>();
|
||||
@@ -75,7 +76,7 @@ export default function SearchDefinitionTab({ note, ntxId }: TabContext) {
|
||||
return (
|
||||
<div className="search-definition-widget">
|
||||
<div className="search-settings">
|
||||
{note &&
|
||||
{note && !hidden &&
|
||||
<table className="search-setting-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -110,10 +111,10 @@ export default function SearchDefinitionTab({ note, ntxId }: TabContext) {
|
||||
})}
|
||||
</tbody>
|
||||
<BulkActionsList note={note} />
|
||||
<tbody>
|
||||
<tbody className="search-actions">
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<div style={{ display: "flex", justifyContent: "space-evenly" }}>
|
||||
<div className="search-actions-container">
|
||||
<Button
|
||||
icon="bx bx-search"
|
||||
text={t("search_definition.search_button")}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useNoteContext } from "../../react/hooks";
|
||||
import { TabContext, TitleContext } from "../ribbon-interface";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { RIBBON_TAB_DEFINITIONS } from "../RibbonDefinition";
|
||||
|
||||
interface StandaloneRibbonAdapterProps {
|
||||
component: (props: TabContext) => ComponentChildren;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes in any ribbon tab component and renders it in standalone mod using the note context, thus requiring no inputs.
|
||||
* Especially useful on mobile to detach components that would normally fit in the ribbon.
|
||||
*/
|
||||
export default function StandaloneRibbonAdapter({ component }: StandaloneRibbonAdapterProps) {
|
||||
const Component = component;
|
||||
const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId } = useNoteContext();
|
||||
const definition = useMemo(() => RIBBON_TAB_DEFINITIONS.find(def => def.content === component), [ component ]);
|
||||
const [ shown, setShown ] = useState(unwrapShown(definition?.show, { note }));
|
||||
|
||||
useEffect(() => {
|
||||
setShown(unwrapShown(definition?.show, { note }));
|
||||
}, [ note ]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
note={note}
|
||||
hidden={!shown}
|
||||
ntxId={ntxId}
|
||||
hoistedNoteId={hoistedNoteId}
|
||||
notePath={notePath}
|
||||
noteContext={noteContext}
|
||||
componentId={componentId}
|
||||
activate={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapShown(value: boolean | ((context: TitleContext) => boolean | null | undefined) | undefined, context: TitleContext) {
|
||||
if (!value) return true;
|
||||
if (typeof value === "boolean") return value;
|
||||
return !!value(context);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { KeyboardActionNames } from "@triliumnext/commons";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { VNode } from "preact";
|
||||
|
||||
export interface TabContext {
|
||||
note: FNote | null | undefined;
|
||||
@@ -11,3 +13,20 @@ export interface TabContext {
|
||||
componentId: string;
|
||||
activate(): void;
|
||||
}
|
||||
|
||||
export interface TitleContext {
|
||||
note: FNote | null | undefined;
|
||||
}
|
||||
|
||||
export interface TabConfiguration {
|
||||
title: string | ((context: TitleContext) => string);
|
||||
icon: string;
|
||||
content: (context: TabContext) => VNode | false;
|
||||
show: boolean | ((context: TitleContext) => boolean | null | undefined);
|
||||
toggleCommand?: KeyboardActionNames;
|
||||
activate?: boolean | ((context: TitleContext) => boolean);
|
||||
/**
|
||||
* By default the tab content will not be rendered unless the tab is active (i.e. selected by the user). Setting to `true` will ensure that the tab is rendered even when inactive, for cases where the tab needs to be accessible at all times (e.g. for the detached editor toolbar) or if event handling is needed.
|
||||
*/
|
||||
stayInDom?: boolean;
|
||||
}
|
||||
|
||||
@@ -376,67 +376,6 @@ body[dir=rtl] .attribute-list-editor {
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Search definition */
|
||||
.search-setting-table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 7px;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 10px;
|
||||
}
|
||||
|
||||
.search-setting-table div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .title-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .dropdown {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .dropdown-menu {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.attribute-list hr {
|
||||
height: 1px;
|
||||
border-color: var(--main-border-color);
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-definition-widget input:invalid {
|
||||
border: 3px solid red;
|
||||
}
|
||||
|
||||
.add-search-option button {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Note actions */
|
||||
.note-actions {
|
||||
width: 35px;
|
||||
|
||||
47
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
47
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal 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;
|
||||
}
|
||||
137
apps/client/src/widgets/type_widgets/Attachment.css
Normal file
137
apps/client/src/widgets/type_widgets/Attachment.css
Normal 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 */
|
||||
303
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
303
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal 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 })}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
4
apps/client/src/widgets/type_widgets/Book.css
Normal file
4
apps/client/src/widgets/type_widgets/Book.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.note-detail-book-empty-help {
|
||||
margin: 50px;
|
||||
padding: 20px;
|
||||
}
|
||||
35
apps/client/src/widgets/type_widgets/Book.tsx
Normal file
35
apps/client/src/widgets/type_widgets/Book.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
apps/client/src/widgets/type_widgets/Canvas.css
Normal file
34
apps/client/src/widgets/type_widgets/Canvas.css
Normal 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%;
|
||||
}
|
||||
325
apps/client/src/widgets/type_widgets/Canvas.tsx
Normal file
325
apps/client/src/widgets/type_widgets/Canvas.tsx
Normal 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 };
|
||||
});
|
||||
}
|
||||
16
apps/client/src/widgets/type_widgets/ContentWidget.css
Normal file
16
apps/client/src/widgets/type_widgets/ContentWidget.css
Normal 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;
|
||||
}
|
||||
58
apps/client/src/widgets/type_widgets/ContentWidget.tsx
Normal file
58
apps/client/src/widgets/type_widgets/ContentWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/client/src/widgets/type_widgets/Doc.css
Normal file
50
apps/client/src/widgets/type_widgets/Doc.css
Normal 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;
|
||||
}
|
||||
36
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
36
apps/client/src/widgets/type_widgets/Doc.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
apps/client/src/widgets/type_widgets/Empty.css
Normal file
38
apps/client/src/widgets/type_widgets/Empty.css
Normal 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%;
|
||||
}
|
||||
85
apps/client/src/widgets/type_widgets/Empty.tsx
Normal file
85
apps/client/src/widgets/type_widgets/Empty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user