mirror of
https://github.com/zadam/trilium.git
synced 2025-11-13 16:55:50 +01:00
Compare commits
293 Commits
v0.99.4
...
react/prom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c8322960 | ||
|
|
3d0d1fa36e | ||
|
|
3f93d191b3 | ||
|
|
3a83e7f632 | ||
|
|
2acede95d7 | ||
|
|
052a60ba58 | ||
|
|
a633c6eecd | ||
|
|
b6561e6375 | ||
|
|
7af9df2ee3 | ||
|
|
3c4b7fd490 | ||
|
|
f5ed4007e3 | ||
|
|
f26469bdc2 | ||
|
|
3326d803a0 | ||
|
|
b60a28c52c | ||
|
|
139c60ac73 | ||
|
|
c7ad6131cb | ||
|
|
86e2a762a4 | ||
|
|
bf9f880a21 | ||
|
|
5491aaab85 | ||
|
|
25563c6687 | ||
|
|
48608adbd3 | ||
|
|
362ecba98d | ||
|
|
a13892da66 | ||
|
|
40568ac547 | ||
|
|
e8dc19a1a6 | ||
|
|
cc326547d1 | ||
|
|
a45b147462 | ||
|
|
4e73f20165 | ||
|
|
0039e5e60c | ||
|
|
813e2e8c9b | ||
|
|
f186e929b2 | ||
|
|
31d2abf954 | ||
|
|
bd69280735 | ||
|
|
80c77eeb18 | ||
|
|
c30282fbd0 | ||
|
|
7b8f1ed6ec | ||
|
|
b86c656895 | ||
|
|
bc32fe749d | ||
|
|
5970a242c9 | ||
|
|
118e11c3fd | ||
|
|
4fc9848a3b | ||
|
|
b8de5b3348 | ||
|
|
5a01f75d67 | ||
|
|
d0a994c102 | ||
|
|
e883f32f89 | ||
|
|
a6fc54cb81 | ||
|
|
76bd6a5ab9 | ||
|
|
3b5d749d86 | ||
|
|
b54765113e | ||
|
|
ed08893996 | ||
|
|
20286d53c8 | ||
|
|
9c1a34fe7c | ||
|
|
e70c6b69b8 | ||
|
|
9b69b0ad0d | ||
|
|
3776c40b8d | ||
|
|
6761f741ca | ||
|
|
c7369bc9b3 | ||
|
|
b741662fde | ||
|
|
624610b17c | ||
|
|
de004bd8ba | ||
|
|
a1c959aabd | ||
|
|
4d1ebd011c | ||
|
|
2947967b79 | ||
|
|
52691b9e88 | ||
|
|
daba806e12 | ||
|
|
d7f7049b5d | ||
|
|
e811db3651 | ||
|
|
a1d38b6bb8 | ||
|
|
c781eab061 | ||
|
|
fb91d4fbd4 | ||
|
|
cd6fcbd283 | ||
|
|
0ccc350ddf | ||
|
|
efa7fd0b7d | ||
|
|
5f4d0325aa | ||
|
|
763fa0b515 | ||
|
|
7ba91b7a9d | ||
|
|
7fc9f08843 | ||
|
|
e28794d706 | ||
|
|
ae1c8f0a0b | ||
|
|
1257e46852 | ||
|
|
40a7f286a3 | ||
|
|
652cccadd7 | ||
|
|
308fd00508 | ||
|
|
35f413505c | ||
|
|
a82d15e83d | ||
|
|
24b169d667 | ||
|
|
b36ef54507 | ||
|
|
c772430dd0 | ||
|
|
d4194c503c | ||
|
|
33a41d2f86 | ||
|
|
319e28387f | ||
|
|
0b740bb007 | ||
|
|
bb7fa9a2e6 | ||
|
|
5e83e6fa34 | ||
|
|
8828e36624 | ||
|
|
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 |
54
.github/workflows/playwright.yml
vendored
54
.github/workflows/playwright.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- hotfix
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "apps/website/**"
|
- "apps/website/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -13,8 +14,24 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: linux-x64
|
||||||
|
os: ubuntu-22.04
|
||||||
|
arch: x64
|
||||||
|
- name: linux-arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
name: E2E tests on ${{ matrix.name }}
|
||||||
|
env:
|
||||||
|
TRILIUM_DOCKER: 1
|
||||||
|
TRILIUM_PORT: 8082
|
||||||
|
TRILIUM_DATA_DIR: "${{ github.workspace }}/apps/server/spec/db"
|
||||||
|
TRILIUM_INTEGRATION_TEST: memory
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -29,9 +46,34 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm exec playwright install --with-deps
|
|
||||||
|
|
||||||
- run: pnpm --filter server-e2e e2e
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build the server
|
||||||
|
uses: ./.github/actions/build-server
|
||||||
|
with:
|
||||||
|
os: linux
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Unpack and start the server
|
||||||
|
run: |
|
||||||
|
version=$(node --eval "console.log(require('./package.json').version)")
|
||||||
|
file=$(find ./upload -name '*.tar.xz' -print -quit)
|
||||||
|
name=$(basename "$file" .tar.xz)
|
||||||
|
mkdir -p ./server-dist
|
||||||
|
tar -xvf "$file" -C ./server-dist
|
||||||
|
server_dir="./server-dist/TriliumNotes-Server-$version-linux-${{ matrix.arch }}"
|
||||||
|
if [ ! -d "$server_dir" ]; then
|
||||||
|
echo Missing dir.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$server_dir"
|
||||||
|
"./trilium.sh" &
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
- name: Server end-to-end tests
|
||||||
|
run: pnpm --filter server-e2e e2e
|
||||||
|
|
||||||
- name: Upload test report
|
- name: Upload test report
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -39,3 +81,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: e2e report
|
name: e2e report
|
||||||
path: apps/server-e2e/test-output
|
path: apps/server-e2e/test-output
|
||||||
|
|
||||||
|
- name: Kill the server
|
||||||
|
if: always()
|
||||||
|
run: pkill -f trilium || true
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Elian Doran <contact@eliandoran.me>",
|
"author": "Elian Doran <contact@eliandoran.me>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"packageManager": "pnpm@10.20.0",
|
"packageManager": "pnpm@10.21.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redocly/cli": "2.11.0",
|
"@redocly/cli": "2.11.1",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"fs-extra": "11.3.2",
|
"fs-extra": "11.3.2",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@triliumnext/client",
|
"name": "@triliumnext/client",
|
||||||
"version": "0.99.4",
|
"version": "0.99.5",
|
||||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"draggabilly": "3.0.0",
|
"draggabilly": "3.0.0",
|
||||||
"force-graph": "1.51.0",
|
"force-graph": "1.51.0",
|
||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
"i18next": "25.6.1",
|
"i18next": "25.6.2",
|
||||||
"i18next-http-backend": "3.0.2",
|
"i18next-http-backend": "3.0.2",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
"jquery.fancytree": "2.38.5",
|
"jquery.fancytree": "2.38.5",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import MainTreeExecutors from "./main_tree_executors.js";
|
|||||||
import toast from "../services/toast.js";
|
import toast from "../services/toast.js";
|
||||||
import ShortcutComponent from "./shortcut_component.js";
|
import ShortcutComponent from "./shortcut_component.js";
|
||||||
import { t, initLocale } from "../services/i18n.js";
|
import { t, initLocale } from "../services/i18n.js";
|
||||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
|
||||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
||||||
@@ -21,8 +20,6 @@ import type LoadResults from "../services/load_results.js";
|
|||||||
import type { Attribute } from "../services/attribute_parser.js";
|
import type { Attribute } from "../services/attribute_parser.js";
|
||||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||||
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
|
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
|
||||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
|
||||||
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
|
|
||||||
import type { NativeImage, TouchBar } from "electron";
|
import type { NativeImage, TouchBar } from "electron";
|
||||||
import TouchBarComponent from "./touch_bar.js";
|
import TouchBarComponent from "./touch_bar.js";
|
||||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||||
@@ -33,6 +30,10 @@ import { ColumnComponent } from "tabulator-tables";
|
|||||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||||
import type RootContainer from "../widgets/containers/root_container.js";
|
import type RootContainer from "../widgets/containers/root_container.js";
|
||||||
import { SqlExecuteResults } from "@triliumnext/commons";
|
import { SqlExecuteResults } from "@triliumnext/commons";
|
||||||
|
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
|
||||||
|
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||||
|
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||||
|
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||||
|
|
||||||
interface Layout {
|
interface Layout {
|
||||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||||
@@ -199,7 +200,7 @@ export type CommandMappings = {
|
|||||||
resetLauncher: ContextMenuCommandData;
|
resetLauncher: ContextMenuCommandData;
|
||||||
|
|
||||||
executeInActiveNoteDetailWidget: CommandData & {
|
executeInActiveNoteDetailWidget: CommandData & {
|
||||||
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
|
callback: (value: ReactWrappedWidget) => void;
|
||||||
};
|
};
|
||||||
executeWithTextEditor: CommandData &
|
executeWithTextEditor: CommandData &
|
||||||
ExecuteCommandData<CKTextEditor> & {
|
ExecuteCommandData<CKTextEditor> & {
|
||||||
@@ -211,7 +212,7 @@ export type CommandMappings = {
|
|||||||
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
|
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
|
||||||
*/
|
*/
|
||||||
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
|
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
|
||||||
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
|
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
|
||||||
addTextToActiveEditor: CommandData & {
|
addTextToActiveEditor: CommandData & {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
@@ -221,9 +222,9 @@ export type CommandMappings = {
|
|||||||
showPasswordNotSet: CommandData;
|
showPasswordNotSet: CommandData;
|
||||||
showProtectedSessionPasswordDialog: CommandData;
|
showProtectedSessionPasswordDialog: CommandData;
|
||||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
|
||||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
showAddLinkDialog: CommandData & AddLinkOpts;
|
||||||
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
|
||||||
closeProtectedSessionPasswordDialog: CommandData;
|
closeProtectedSessionPasswordDialog: CommandData;
|
||||||
copyImageReferenceToClipboard: CommandData;
|
copyImageReferenceToClipboard: CommandData;
|
||||||
copyImageToClipboard: CommandData;
|
copyImageToClipboard: CommandData;
|
||||||
@@ -328,6 +329,7 @@ export type CommandMappings = {
|
|||||||
exportAsPdf: CommandData;
|
exportAsPdf: CommandData;
|
||||||
openNoteExternally: CommandData;
|
openNoteExternally: CommandData;
|
||||||
openNoteCustom: CommandData;
|
openNoteCustom: CommandData;
|
||||||
|
openNoteOnServer: CommandData;
|
||||||
renderActiveNote: CommandData;
|
renderActiveNote: CommandData;
|
||||||
unhoist: CommandData;
|
unhoist: CommandData;
|
||||||
reloadFrontendApp: CommandData;
|
reloadFrontendApp: CommandData;
|
||||||
@@ -485,13 +487,8 @@ type EventMappings = {
|
|||||||
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
||||||
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
||||||
activeNoteChanged: {};
|
activeNoteChanged: {};
|
||||||
showAddLinkDialog: {
|
showAddLinkDialog: AddLinkOpts;
|
||||||
textTypeWidget: EditableTextTypeWidget;
|
showIncludeDialog: IncludeNoteOpts;
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
showIncludeDialog: {
|
|
||||||
textTypeWidget: EditableTextTypeWidget;
|
|
||||||
};
|
|
||||||
openBulkActionsDialog: {
|
openBulkActionsDialog: {
|
||||||
selectedOrActiveNoteIds: string[];
|
selectedOrActiveNoteIds: string[];
|
||||||
};
|
};
|
||||||
@@ -670,6 +667,10 @@ export class AppContext extends Component {
|
|||||||
this.beforeUnloadListeners.push(obj);
|
this.beforeUnloadListeners.push(obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeBeforeUnloadListener(listener: (() => boolean)) {
|
||||||
|
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const appContext = new AppContext(window.glob.isMainWindow);
|
const appContext = new AppContext(window.glob.isMainWindow);
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import hoistedNoteService from "../services/hoisted_note.js";
|
|||||||
import options from "../services/options.js";
|
import options from "../services/options.js";
|
||||||
import type { ViewScope } from "../services/link.js";
|
import type { ViewScope } from "../services/link.js";
|
||||||
import type FNote from "../entities/fnote.js";
|
import type FNote from "../entities/fnote.js";
|
||||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
|
||||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||||
import type CodeMirror from "@triliumnext/codemirror";
|
import type CodeMirror from "@triliumnext/codemirror";
|
||||||
import { closeActiveDialog } from "../services/dialog.js";
|
import { closeActiveDialog } from "../services/dialog.js";
|
||||||
|
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||||
|
|
||||||
export interface SetNoteOpts {
|
export interface SetNoteOpts {
|
||||||
triggerSwitchEvent?: unknown;
|
triggerSwitchEvent?: unknown;
|
||||||
@@ -397,7 +397,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
|||||||
|
|
||||||
async getTypeWidget() {
|
async getTypeWidget() {
|
||||||
return this.timeout(
|
return this.timeout(
|
||||||
new Promise<TypeWidget | null>((resolve) =>
|
new Promise<ReactWrappedWidget | null>((resolve) =>
|
||||||
appContext.triggerCommand("executeWithTypeWidget", {
|
appContext.triggerCommand("executeWithTypeWidget", {
|
||||||
resolve,
|
resolve,
|
||||||
ntxId: this.ntxId
|
ntxId: this.ntxId
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ export default class RootCommandExecutor extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openNoteOnServerCommand() {
|
||||||
|
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||||
|
if (noteId) {
|
||||||
|
openService.openNoteOnServer(noteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enterProtectedSessionCommand() {
|
enterProtectedSessionCommand() {
|
||||||
protectedSessionService.enterProtectedSession();
|
protectedSessionService.enterProtectedSession();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export default class TabManager extends Component {
|
|||||||
mainNtxId: string | null = null
|
mainNtxId: string | null = null
|
||||||
): Promise<NoteContext> {
|
): Promise<NoteContext> {
|
||||||
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
|
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
|
||||||
|
noteContext.setEmpty();
|
||||||
|
|
||||||
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
|
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import LauncherContainer from "../widgets/containers/launcher_container.js";
|
|||||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
|
||||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||||
@@ -22,7 +21,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
|||||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||||
import options from "../services/options.js";
|
import options from "../services/options.js";
|
||||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
|
||||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||||
@@ -44,6 +42,8 @@ import type { WidgetsByParent } from "../services/bundle.js";
|
|||||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||||
import utils from "../services/utils.js";
|
import utils from "../services/utils.js";
|
||||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||||
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
|
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||||
|
|
||||||
export default class DesktopLayout {
|
export default class DesktopLayout {
|
||||||
|
|
||||||
@@ -140,9 +140,9 @@ export default class DesktopLayout {
|
|||||||
.child(<ReadOnlyNoteInfoBar />)
|
.child(<ReadOnlyNoteInfoBar />)
|
||||||
.child(<SharedInfo />)
|
.child(<SharedInfo />)
|
||||||
)
|
)
|
||||||
.child(new PromotedAttributesWidget())
|
.child(<PromotedAttributes />)
|
||||||
.child(<SqlTableSchemas />)
|
.child(<SqlTableSchemas />)
|
||||||
.child(new NoteDetailWidget())
|
.child(<NoteDetail />)
|
||||||
.child(<NoteList media="screen" />)
|
.child(<NoteList media="screen" />)
|
||||||
.child(<SearchResult />)
|
.child(<SearchResult />)
|
||||||
.child(<SqlResults />)
|
.child(<SqlResults />)
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
|
|||||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||||
import NoteIconWidget from "../widgets/note_icon";
|
import NoteIconWidget from "../widgets/note_icon";
|
||||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
|
||||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||||
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
|
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
|
||||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||||
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||||
|
|
||||||
export function applyModals(rootContainer: RootContainer) {
|
export function applyModals(rootContainer: RootContainer) {
|
||||||
@@ -66,7 +66,7 @@ export function applyModals(rootContainer: RootContainer) {
|
|||||||
.child(<NoteTitleWidget />))
|
.child(<NoteTitleWidget />))
|
||||||
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
|
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
|
||||||
.child(new PromotedAttributesWidget())
|
.child(new PromotedAttributesWidget())
|
||||||
.child(new NoteDetailWidget())
|
.child(<NoteDetail />)
|
||||||
.child(<NoteList media="screen" displayOnlyCollections />))
|
.child(<NoteList media="screen" displayOnlyCollections />))
|
||||||
.child(<CallToActionDialog />);
|
.child(<CallToActionDialog />);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
|||||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
|
||||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
|
||||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||||
import NoteTitleWidget from "../widgets/note_title.js";
|
import NoteTitleWidget from "../widgets/note_title.js";
|
||||||
import ContentHeader from "../widgets/containers/content-header.js";
|
import ContentHeader from "../widgets/containers/content-header.js";
|
||||||
@@ -29,6 +27,8 @@ import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibb
|
|||||||
import TabRowWidget from "../widgets/tab_row.js";
|
import TabRowWidget from "../widgets/tab_row.js";
|
||||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||||
import type AppContext from "../components/app_context.js";
|
import type AppContext from "../components/app_context.js";
|
||||||
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
|
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||||
|
|
||||||
const MOBILE_CSS = `
|
const MOBILE_CSS = `
|
||||||
<style>
|
<style>
|
||||||
@@ -161,7 +161,7 @@ export default class MobileLayout {
|
|||||||
.child(<ReadOnlyNoteInfoBar />)
|
.child(<ReadOnlyNoteInfoBar />)
|
||||||
.child(<SharedInfoWidget />)
|
.child(<SharedInfoWidget />)
|
||||||
)
|
)
|
||||||
.child(new NoteDetailWidget())
|
.child(<NoteDetail />)
|
||||||
.child(<NoteList media="screen" />)
|
.child(<NoteList media="screen" />)
|
||||||
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
||||||
.child(<SearchResult />)
|
.child(<SearchResult />)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
|
|||||||
import ws from "./ws.js";
|
import ws from "./ws.js";
|
||||||
import appContext from "../components/app_context.js";
|
import appContext from "../components/app_context.js";
|
||||||
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
|
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
|
||||||
import BasicWidget from "../widgets/basic_widget.js";
|
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||||
import SpacedUpdate from "./spaced_update.js";
|
import SpacedUpdate from "./spaced_update.js";
|
||||||
import shortcutService from "./shortcuts.js";
|
import shortcutService from "./shortcuts.js";
|
||||||
import dialogService from "./dialog.js";
|
import dialogService from "./dialog.js";
|
||||||
@@ -19,7 +19,6 @@ import type FNote from "../entities/fnote.js";
|
|||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type NoteContext from "../components/note_context.js";
|
import type NoteContext from "../components/note_context.js";
|
||||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
|
||||||
import type Component from "../components/component.js";
|
import type Component from "../components/component.js";
|
||||||
import { formatLogMessage } from "@triliumnext/commons";
|
import { formatLogMessage } from "@triliumnext/commons";
|
||||||
|
|
||||||
@@ -317,7 +316,7 @@ export interface Api {
|
|||||||
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
|
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
|
||||||
* implementation of actual widget type.
|
* implementation of actual widget type.
|
||||||
*/
|
*/
|
||||||
getActiveNoteDetailWidget(): Promise<NoteDetailWidget>;
|
getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>;
|
||||||
/**
|
/**
|
||||||
* @returns returns a note path of active note or null if there isn't active note
|
* @returns returns a note path of active note or null if there isn't active note
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import server from "./server.js";
|
import server from "./server.js";
|
||||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
import appContext from "../components/app_context.js";
|
||||||
import shortcutService from "./shortcuts.js";
|
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||||
import type Component from "../components/component.js";
|
import type Component from "../components/component.js";
|
||||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||||
|
|
||||||
@@ -30,12 +30,18 @@ async function getActionsForScope(scope: string) {
|
|||||||
|
|
||||||
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
|
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
|
||||||
const actions = await getActionsForScope(scope);
|
const actions = await getActionsForScope(scope);
|
||||||
|
const bindings: ShortcutBinding[] = [];
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||||
|
if (binding) {
|
||||||
|
bindings.push(binding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings;
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionsForScope("window").then((actions) => {
|
getActionsForScope("window").then((actions) => {
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
|
|||||||
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
|
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
|
||||||
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
|
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
|
||||||
*/
|
*/
|
||||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
||||||
if (hrefLink?.startsWith("data:")) {
|
if (hrefLink?.startsWith("data:")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils from "./utils.js";
|
import utils from "./utils.js";
|
||||||
|
import options from "./options.js";
|
||||||
import server from "./server.js";
|
import server from "./server.js";
|
||||||
|
|
||||||
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
|
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
|
||||||
@@ -126,7 +127,7 @@ function downloadRevision(noteId: string, revisionId: string) {
|
|||||||
/**
|
/**
|
||||||
* @param url - should be without initial slash!!!
|
* @param url - should be without initial slash!!!
|
||||||
*/
|
*/
|
||||||
function getUrlForDownload(url: string) {
|
export function getUrlForDownload(url: string) {
|
||||||
if (utils.isElectron()) {
|
if (utils.isElectron()) {
|
||||||
// electron needs absolute URL, so we extract current host, port, protocol
|
// electron needs absolute URL, so we extract current host, port, protocol
|
||||||
return `${getHost()}/${url}`;
|
return `${getHost()}/${url}`;
|
||||||
@@ -171,6 +172,21 @@ function getHost() {
|
|||||||
return `${url.protocol}//${url.hostname}:${url.port}`;
|
return `${url.protocol}//${url.hostname}:${url.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openNoteOnServer(noteId: string) {
|
||||||
|
// Get the sync server host from options
|
||||||
|
const syncServerHost = options.get("syncServerHost");
|
||||||
|
|
||||||
|
if (!syncServerHost) {
|
||||||
|
console.error("No sync server host configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`#root/${noteId}`, syncServerHost).toString();
|
||||||
|
|
||||||
|
// Use window.open to ensure link opens in external browser in Electron
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
async function openDirectory(directory: string) {
|
async function openDirectory(directory: string) {
|
||||||
try {
|
try {
|
||||||
if (utils.isElectron()) {
|
if (utils.isElectron()) {
|
||||||
@@ -198,5 +214,6 @@ export default {
|
|||||||
openAttachmentExternally,
|
openAttachmentExternally,
|
||||||
openNoteCustom,
|
openNoteCustom,
|
||||||
openAttachmentCustom,
|
openAttachmentCustom,
|
||||||
|
openNoteOnServer,
|
||||||
openDirectory
|
openDirectory
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import utils from "./utils.js";
|
|||||||
type ElementType = HTMLElement | Document;
|
type ElementType = HTMLElement | Document;
|
||||||
type Handler = (e: KeyboardEvent) => void;
|
type Handler = (e: KeyboardEvent) => void;
|
||||||
|
|
||||||
interface ShortcutBinding {
|
export interface ShortcutBinding {
|
||||||
element: HTMLElement | Document;
|
element: HTMLElement | Document;
|
||||||
shortcut: string;
|
shortcut: string;
|
||||||
handler: Handler;
|
handler: Handler;
|
||||||
@@ -127,10 +127,20 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
|||||||
activeBindings.set(key, []);
|
activeBindings.set(key, []);
|
||||||
}
|
}
|
||||||
activeBindings.get(key)!.push(binding);
|
activeBindings.get(key)!.push(binding);
|
||||||
|
return binding;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeIndividualBinding(binding: ShortcutBinding) {
|
||||||
|
const key = binding.namespace ?? "global";
|
||||||
|
const activeBindingsInNamespace = activeBindings.get(key);
|
||||||
|
if (activeBindingsInNamespace) {
|
||||||
|
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
|
||||||
|
}
|
||||||
|
binding.element.removeEventListener("keydown", binding.listener);
|
||||||
|
}
|
||||||
|
|
||||||
function removeNamespaceBindings(namespace: string) {
|
function removeNamespaceBindings(namespace: string) {
|
||||||
const bindings = activeBindings.get(namespace);
|
const bindings = activeBindings.get(namespace);
|
||||||
if (bindings) {
|
if (bindings) {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (glob.device !== "print") {
|
||||||
applyCopyToClipboardButton($(codeBlock));
|
applyCopyToClipboardButton($(codeBlock));
|
||||||
|
}
|
||||||
|
|
||||||
if (syntaxHighlightingEnabled) {
|
if (syntaxHighlightingEnabled) {
|
||||||
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
|
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const entityMap: Record<string, string> = {
|
|||||||
"=": "="
|
"=": "="
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(str: string) {
|
export function escapeHtml(str: string) {
|
||||||
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ body.desktop .tabulator-popup-container,
|
|||||||
.dropdown-menu .disabled .disabled-tooltip {
|
.dropdown-menu .disabled .disabled-tooltip {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
margin-inline-start: 8px;
|
margin-inline-start: 8px;
|
||||||
font-size: 0.5em;
|
font-size: 0.75rem;
|
||||||
color: var(--disabled-tooltip-icon-color);
|
color: var(--disabled-tooltip-icon-color);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
@@ -2045,7 +2045,7 @@ body.zen .title-row .icon-action,
|
|||||||
body.zen .promoted-attributes-widget,
|
body.zen .promoted-attributes-widget,
|
||||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||||
body.zen .action-button,
|
body.zen .action-button,
|
||||||
body.zen .note-list-widget:not(.full-height) {
|
body.zen .note-split:not(.type-book) .note-list-widget {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
|||||||
transition: background-color 200ms ease-out;
|
transition: background-color 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root .note-list .note-book-card:active {
|
:root .note-list.grid-view .note-book-card:active {
|
||||||
transform: scale(.98);
|
transform: scale(.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,9 +581,14 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-list-wrapper .note-book-card .note-book-content.type-code {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
|
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list-wrapper .note-book-card .bx {
|
.note-list-wrapper .note-book-card .bx {
|
||||||
|
|||||||
@@ -123,8 +123,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* The container */
|
/* The container */
|
||||||
div.note-detail-empty {
|
|
||||||
max-width: 70%;
|
.note-split.empty-note {
|
||||||
|
--max-content-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-split.empty-note div.note-detail {
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ div.promoted-attributes-container {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* The property label */
|
/* The property label */
|
||||||
.note-info-widget-table th,
|
.note-info-item > span:first-child,
|
||||||
.file-properties-widget .file-table th,
|
.file-properties-widget .file-table th,
|
||||||
.image-properties > div:first-child > span > strong {
|
.image-properties > div:first-child > span > strong {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -50,7 +50,6 @@ div.promoted-attributes-container {
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-info-widget-table td,
|
|
||||||
.file-properties-widget .file-table td {
|
.file-properties-widget .file-table td {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -995,7 +995,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
"start_session_button": "开始受保护的会话",
|
||||||
"started": "受保护的会话已启动。",
|
"started": "受保护的会话已启动。",
|
||||||
"wrong_password": "密码错误。",
|
"wrong_password": "密码错误。",
|
||||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||||
|
|||||||
@@ -989,7 +989,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
||||||
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
"start_session_button": "Starte eine geschützte Sitzung",
|
||||||
"started": "Geschützte Sitzung gestartet.",
|
"started": "Geschützte Sitzung gestartet.",
|
||||||
"wrong_password": "Passwort falsch.",
|
"wrong_password": "Passwort falsch.",
|
||||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||||
|
|||||||
@@ -682,6 +682,7 @@
|
|||||||
"open_note_externally": "Open note externally",
|
"open_note_externally": "Open note externally",
|
||||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||||
"open_note_custom": "Open note custom",
|
"open_note_custom": "Open note custom",
|
||||||
|
"open_note_on_server": "Open note on server",
|
||||||
"import_files": "Import files",
|
"import_files": "Import files",
|
||||||
"export_note": "Export note",
|
"export_note": "Export note",
|
||||||
"delete_note": "Delete note",
|
"delete_note": "Delete note",
|
||||||
@@ -995,7 +996,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Showing protected note requires entering your password:",
|
"enter_password_instruction": "Showing protected note requires entering your password:",
|
||||||
"start_session_button": "Start protected session <kbd>enter</kbd>",
|
"start_session_button": "Start protected session",
|
||||||
"started": "Protected session has been started.",
|
"started": "Protected session has been started.",
|
||||||
"wrong_password": "Wrong password.",
|
"wrong_password": "Wrong password.",
|
||||||
"protecting-finished-successfully": "Protecting finished successfully.",
|
"protecting-finished-successfully": "Protecting finished successfully.",
|
||||||
|
|||||||
@@ -39,7 +39,10 @@
|
|||||||
"help_on_tree_prefix": "Ayuda sobre el prefijo del árbol",
|
"help_on_tree_prefix": "Ayuda sobre el prefijo del árbol",
|
||||||
"prefix": "Prefijo: ",
|
"prefix": "Prefijo: ",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"branch_prefix_saved": "Se ha guardado el prefijo de rama."
|
"branch_prefix_saved": "Se ha guardado el prefijo de rama.",
|
||||||
|
"edit_branch_prefix_multiple": "Editar prefijo de rama para {{count}} ramas",
|
||||||
|
"branch_prefix_saved_multiple": "El prefijo de rama se ha guardado para {{count}} ramas.",
|
||||||
|
"affected_branches": "Ramas afectadas ({{count}}):"
|
||||||
},
|
},
|
||||||
"bulk_actions": {
|
"bulk_actions": {
|
||||||
"bulk_actions": "Acciones en bloque",
|
"bulk_actions": "Acciones en bloque",
|
||||||
@@ -992,7 +995,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
|
"enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
|
||||||
"start_session_button": "Iniciar sesión protegida <kbd>Enter</kbd>",
|
"start_session_button": "Iniciar sesión protegida",
|
||||||
"started": "La sesión protegida ha iniciado.",
|
"started": "La sesión protegida ha iniciado.",
|
||||||
"wrong_password": "Contraseña incorrecta.",
|
"wrong_password": "Contraseña incorrecta.",
|
||||||
"protecting-finished-successfully": "La protección finalizó exitosamente.",
|
"protecting-finished-successfully": "La protección finalizó exitosamente.",
|
||||||
@@ -1107,7 +1110,8 @@
|
|||||||
"title": "Ancho del contenido",
|
"title": "Ancho del contenido",
|
||||||
"default_description": "Trilium limita de forma predeterminada el ancho máximo del contenido para mejorar la legibilidad de ventanas maximizadas en pantallas anchas.",
|
"default_description": "Trilium limita de forma predeterminada el ancho máximo del contenido para mejorar la legibilidad de ventanas maximizadas en pantallas anchas.",
|
||||||
"max_width_label": "Ancho máximo del contenido en píxeles",
|
"max_width_label": "Ancho máximo del contenido en píxeles",
|
||||||
"max_width_unit": "píxeles"
|
"max_width_unit": "píxeles",
|
||||||
|
"centerContent": "Mantener el contenido centrado"
|
||||||
},
|
},
|
||||||
"native_title_bar": {
|
"native_title_bar": {
|
||||||
"title": "Barra de título nativa (requiere reiniciar la aplicación)",
|
"title": "Barra de título nativa (requiere reiniciar la aplicación)",
|
||||||
@@ -2079,5 +2083,14 @@
|
|||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
"rendering_error": "No se puede mostrar contenido debido a un error."
|
"rendering_error": "No se puede mostrar contenido debido a un error."
|
||||||
|
},
|
||||||
|
"read-only-info": {
|
||||||
|
"read-only-note": "Actualmente, está viendo una nota de solo lectura.",
|
||||||
|
"auto-read-only-note": "Esta nota se muestra en modo de solo lectura para una carga más rápida.",
|
||||||
|
"auto-read-only-learn-more": "Para saber más",
|
||||||
|
"edit-note": "Editar nota"
|
||||||
|
},
|
||||||
|
"calendar_view": {
|
||||||
|
"delete_note": "Eliminar nota..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -991,7 +991,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :",
|
"enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :",
|
||||||
"start_session_button": "Démarrer une session protégée <kbd>Entrée</kbd>",
|
"start_session_button": "Démarrer une session protégée",
|
||||||
"started": "La session protégée a démarré.",
|
"started": "La session protégée a démarré.",
|
||||||
"wrong_password": "Mot de passe incorrect.",
|
"wrong_password": "Mot de passe incorrect.",
|
||||||
"protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",
|
"protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",
|
||||||
|
|||||||
@@ -39,7 +39,10 @@
|
|||||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||||
"prefix": "Prefisso: ",
|
"prefix": "Prefisso: ",
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
"branch_prefix_saved": "Il prefisso del ramo è stato salvato.",
|
||||||
|
"edit_branch_prefix_multiple": "Modifica prefisso ramo per {{count}} rami",
|
||||||
|
"branch_prefix_saved_multiple": "Il prefisso del ramo è stato salvato per {{count}} rami.",
|
||||||
|
"affected_branches": "Rami interessati ({{count}}):"
|
||||||
},
|
},
|
||||||
"bulk_actions": {
|
"bulk_actions": {
|
||||||
"bulk_actions": "Azioni massive",
|
"bulk_actions": "Azioni massive",
|
||||||
@@ -1499,7 +1502,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:",
|
"enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:",
|
||||||
"start_session_button": "Avvia sessione protetta <kbd>invio</kbd>",
|
"start_session_button": "Avvia sessione protetta",
|
||||||
"started": "La sessione protetta è stata avviata.",
|
"started": "La sessione protetta è stata avviata.",
|
||||||
"wrong_password": "Password errata.",
|
"wrong_password": "Password errata.",
|
||||||
"protecting-finished-successfully": "Protezione completata con successo.",
|
"protecting-finished-successfully": "Protezione completata con successo.",
|
||||||
@@ -1570,7 +1573,8 @@
|
|||||||
"title": "Larghezza del contenuto",
|
"title": "Larghezza del contenuto",
|
||||||
"default_description": "Per impostazione predefinita, Trilium limita la larghezza massima del contenuto per migliorare la leggibilità sugli schermi più grandi.",
|
"default_description": "Per impostazione predefinita, Trilium limita la larghezza massima del contenuto per migliorare la leggibilità sugli schermi più grandi.",
|
||||||
"max_width_label": "Larghezza massima del contenuto",
|
"max_width_label": "Larghezza massima del contenuto",
|
||||||
"max_width_unit": "pixel"
|
"max_width_unit": "pixel",
|
||||||
|
"centerContent": "Mantieni il contenuto centrato"
|
||||||
},
|
},
|
||||||
"native_title_bar": {
|
"native_title_bar": {
|
||||||
"title": "Barra del titolo nativa (richiede il riavvio dell'app)",
|
"title": "Barra del titolo nativa (richiede il riavvio dell'app)",
|
||||||
@@ -2080,5 +2084,14 @@
|
|||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
"rendering_error": "Impossibile mostrare il contenuto a causa di un errore."
|
"rendering_error": "Impossibile mostrare il contenuto a causa di un errore."
|
||||||
|
},
|
||||||
|
"read-only-info": {
|
||||||
|
"read-only-note": "Stai visualizzando una nota di sola lettura.",
|
||||||
|
"auto-read-only-note": "Questa nota viene visualizzata in modalità di sola lettura per un caricamento più rapido.",
|
||||||
|
"auto-read-only-learn-more": "Per saperne di più",
|
||||||
|
"edit-note": "Modifica nota"
|
||||||
|
},
|
||||||
|
"calendar_view": {
|
||||||
|
"delete_note": "Eliminazione nota..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -836,7 +836,8 @@
|
|||||||
"title": "コンテンツ幅",
|
"title": "コンテンツ幅",
|
||||||
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
|
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
|
||||||
"max_width_label": "最大コンテンツ幅",
|
"max_width_label": "最大コンテンツ幅",
|
||||||
"max_width_unit": "ピクセル"
|
"max_width_unit": "ピクセル",
|
||||||
|
"centerContent": "コンテンツを中央に配置"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "アプリのテーマ",
|
"title": "アプリのテーマ",
|
||||||
@@ -1783,7 +1784,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:",
|
"enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:",
|
||||||
"start_session_button": "保護されたセッションを開始 <kbd>enter</kbd>",
|
"start_session_button": "保護されたセッションを開始",
|
||||||
"started": "保護されたセッションが開始されました。",
|
"started": "保護されたセッションが開始されました。",
|
||||||
"wrong_password": "パスワードが間違っています。",
|
"wrong_password": "パスワードが間違っています。",
|
||||||
"protecting-finished-successfully": "保護が正常に完了しました。",
|
"protecting-finished-successfully": "保護が正常に完了しました。",
|
||||||
@@ -2082,5 +2083,11 @@
|
|||||||
},
|
},
|
||||||
"calendar_view": {
|
"calendar_view": {
|
||||||
"delete_note": "ノートを削除..."
|
"delete_note": "ノートを削除..."
|
||||||
|
},
|
||||||
|
"read-only-info": {
|
||||||
|
"read-only-note": "現在、読み取り専用のノートを表示しています。",
|
||||||
|
"auto-read-only-note": "このノートは読み込みを高速化するために読み取り専用モードで表示されています。",
|
||||||
|
"auto-read-only-learn-more": "さらに詳しく",
|
||||||
|
"edit-note": "ノートを編集"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -967,7 +967,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:",
|
"enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:",
|
||||||
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>",
|
"start_session_button": "Iniciar sessão protegida",
|
||||||
"started": "A sessão protegida foi iniciada.",
|
"started": "A sessão protegida foi iniciada.",
|
||||||
"wrong_password": "Palavra-passe incorreta.",
|
"wrong_password": "Palavra-passe incorreta.",
|
||||||
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.",
|
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.",
|
||||||
|
|||||||
@@ -1218,7 +1218,7 @@
|
|||||||
"unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
|
"unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
|
||||||
"protecting-title": "Estado da proteção",
|
"protecting-title": "Estado da proteção",
|
||||||
"unprotecting-title": "Estado da remoção de proteção",
|
"unprotecting-title": "Estado da remoção de proteção",
|
||||||
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>"
|
"start_session_button": "Iniciar sessão protegida"
|
||||||
},
|
},
|
||||||
"relation_map": {
|
"relation_map": {
|
||||||
"open_in_new_tab": "Abrir em nova aba",
|
"open_in_new_tab": "Abrir em nova aba",
|
||||||
|
|||||||
@@ -982,7 +982,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
|
"enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
|
||||||
"start_session_button": "Deschide sesiunea protejată <kbd>enter</kbd>",
|
"start_session_button": "Deschide sesiunea protejată",
|
||||||
"started": "Sesiunea protejată este activă.",
|
"started": "Sesiunea protejată este activă.",
|
||||||
"wrong_password": "Parolă greșită.",
|
"wrong_password": "Parolă greșită.",
|
||||||
"protecting-finished-successfully": "Protejarea a avut succes.",
|
"protecting-finished-successfully": "Protejarea a avut succes.",
|
||||||
|
|||||||
@@ -1685,7 +1685,7 @@
|
|||||||
"unprotecting-title": "Статус снятия защиты",
|
"unprotecting-title": "Статус снятия защиты",
|
||||||
"protecting-finished-successfully": "Защита успешно завершена.",
|
"protecting-finished-successfully": "Защита успешно завершена.",
|
||||||
"unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
|
"unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
|
||||||
"start_session_button": "Начать защищенный сеанс <kbd>enter</kbd>",
|
"start_session_button": "Начать защищенный сеанс",
|
||||||
"protecting-in-progress": "Защита в процессе: {{count}}",
|
"protecting-in-progress": "Защита в процессе: {{count}}",
|
||||||
"unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
|
"unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
|
||||||
"started": "Защищенный сеанс запущен.",
|
"started": "Защищенный сеанс запущен.",
|
||||||
|
|||||||
@@ -992,7 +992,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
||||||
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
|
"start_session_button": "開始受保護的作業階段",
|
||||||
"started": "已啟動受保護的作業階段。",
|
"started": "已啟動受保護的作業階段。",
|
||||||
"wrong_password": "密碼錯誤。",
|
"wrong_password": "密碼錯誤。",
|
||||||
"protecting-finished-successfully": "已成功完成保護操作。",
|
"protecting-finished-successfully": "已成功完成保護操作。",
|
||||||
|
|||||||
@@ -1089,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
|
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
|
||||||
"start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>",
|
"start_session_button": "Розпочати захищений сеанс",
|
||||||
"started": "Захищений сеанс розпочато.",
|
"started": "Захищений сеанс розпочато.",
|
||||||
"wrong_password": "Неправильний пароль.",
|
"wrong_password": "Неправильний пароль.",
|
||||||
"protecting-finished-successfully": "Захист успішно завершено.",
|
"protecting-finished-successfully": "Захист успішно завершено.",
|
||||||
|
|||||||
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;
|
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
@@ -118,11 +118,17 @@ declare global {
|
|||||||
filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
|
filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface PanZoomTransform {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface PanZoom {
|
interface PanZoom {
|
||||||
zoomTo(x: number, y: number, scale: number);
|
zoomTo(x: number, y: number, scale: number);
|
||||||
moveTo(x: number, y: number);
|
moveTo(x: number, y: number);
|
||||||
on(event: string, callback: () => void);
|
on(event: string, callback: () => void);
|
||||||
getTransform(): unknown;
|
getTransform(): PanZoomTransform;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||||
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
|
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
|
||||||
return isEnabled && <FloatingButton
|
return isEnabled && <FloatingButton
|
||||||
text={t("backend_log.refresh")}
|
text={t("backend_log.refresh")}
|
||||||
icon="bx bx-refresh"
|
icon="bx bx-refresh"
|
||||||
@@ -89,7 +89,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
|
|||||||
|
|
||||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||||
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
|
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
|
||||||
&& note.isContentAvailable() && isDefaultViewMode;
|
&& note.isContentAvailable() && isDefaultViewMode;
|
||||||
|
|
||||||
return isEnabled && <FloatingButton
|
return isEnabled && <FloatingButton
|
||||||
@@ -240,7 +240,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
|
|||||||
|
|
||||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||||
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
||||||
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
|
const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
|
||||||
&& note?.isContentAvailable() && isDefaultViewMode;
|
&& note?.isContentAvailable() && isDefaultViewMode;
|
||||||
|
|
||||||
return isEnabled && (
|
return isEnabled && (
|
||||||
|
|||||||
15
apps/client/src/widgets/NoteDetail.css
Normal file
15
apps/client/src/widgets/NoteDetail.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.component.note-detail {
|
||||||
|
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||||
|
font-family: var(--detail-font-family);
|
||||||
|
font-size: var(--detail-font-size);
|
||||||
|
contain: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.prefers-centered-content .note-detail {
|
||||||
|
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail > * {
|
||||||
|
contain: none;
|
||||||
|
}
|
||||||
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 } from "./react/hooks"
|
||||||
|
import FNote from "../entities/fnote";
|
||||||
|
import protected_session_holder from "../services/protected_session_holder";
|
||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import NoteContext from "../components/note_context";
|
||||||
|
import { isValidElement, VNode } from "preact";
|
||||||
|
import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||||
|
import "./NoteDetail.css";
|
||||||
|
import attributes from "../services/attributes";
|
||||||
|
import { ExtendedNoteType, TYPE_MAPPINGS, TypeWidget } from "./note_types";
|
||||||
|
import { dynamicRequire, isElectron, isMobile } from "../services/utils";
|
||||||
|
import toast from "../services/toast.js";
|
||||||
|
import { t } from "../services/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note detail is in charge of rendering the content of a note, by determining its type (e.g. text, code) and using the appropriate view widget.
|
||||||
|
*
|
||||||
|
* Apart from that, it:
|
||||||
|
* - Applies a full-height style depending on the content type (e.g. canvas notes).
|
||||||
|
* - Focuses the content when switching tabs.
|
||||||
|
* - Caches the note type elements based on what the user has accessed, in order to quickly load it again.
|
||||||
|
* - Fixes the tree for launch bar configurations on mobile.
|
||||||
|
* - Provides scripting events such as obtaining the active note detail widget, or note type widget.
|
||||||
|
* - Printing and exporting to PDF.
|
||||||
|
*/
|
||||||
|
export default function NoteDetail() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { note, type, mime, noteContext, parentComponent } = useNoteInfo();
|
||||||
|
const { ntxId, viewScope } = noteContext ?? {};
|
||||||
|
const isFullHeight = checkFullHeight(noteContext, type);
|
||||||
|
const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
|
||||||
|
const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
|
||||||
|
|
||||||
|
const props: TypeWidgetProps = {
|
||||||
|
note: note!,
|
||||||
|
viewScope,
|
||||||
|
ntxId,
|
||||||
|
parentComponent,
|
||||||
|
noteContext
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!type) return;
|
||||||
|
|
||||||
|
if (!noteTypesToRender.current[type]) {
|
||||||
|
getCorrespondingWidget(type).then((el) => {
|
||||||
|
if (!el) return;
|
||||||
|
noteTypesToRender.current[type] = el;
|
||||||
|
setActiveNoteType(type);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setActiveNoteType(type);
|
||||||
|
}
|
||||||
|
}, [ note, viewScope, type ]);
|
||||||
|
|
||||||
|
// Detect note type changes.
|
||||||
|
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||||
|
if (!note) return;
|
||||||
|
|
||||||
|
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
|
||||||
|
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
|
||||||
|
// times if the same note is open in several tabs.
|
||||||
|
|
||||||
|
if (note.noteId && loadResults.isNoteContentReloaded(note.noteId, parentComponent.componentId)) {
|
||||||
|
// probably incorrect event
|
||||||
|
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||||
|
// FIXME: create a separate event to force hierarchical refresh
|
||||||
|
|
||||||
|
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
|
||||||
|
// to avoid the problem in #3365
|
||||||
|
parentComponent.handleEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||||
|
} else if (note.noteId
|
||||||
|
&& loadResults.isNoteReloaded(note.noteId, parentComponent.componentId)
|
||||||
|
&& (type !== (await getWidgetType(note, noteContext)) || mime !== note?.mime)) {
|
||||||
|
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
|
||||||
|
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||||
|
} else {
|
||||||
|
const attrs = loadResults.getAttributeRows();
|
||||||
|
|
||||||
|
const label = attrs.find(
|
||||||
|
(attr) =>
|
||||||
|
attr.type === "label" &&
|
||||||
|
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
|
||||||
|
attributes.isAffecting(attr, note)
|
||||||
|
);
|
||||||
|
|
||||||
|
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"]
|
||||||
|
.includes(attr.name ?? "") && attributes.isAffecting(attr, note));
|
||||||
|
|
||||||
|
if (note.noteId && (label || relation)) {
|
||||||
|
// probably incorrect event
|
||||||
|
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||||
|
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically focus the editor.
|
||||||
|
useTriliumEvent("activeNoteChanged", () => {
|
||||||
|
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
|
||||||
|
if (!document.activeElement?.classList.contains("fancytree-title")) {
|
||||||
|
parentComponent.triggerCommand("focusOnDetail", { ntxId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fixed tree for launch bar config on mobile.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
const hasFixedTree = noteContext?.hoistedNoteId === "_lbMobileRoot";
|
||||||
|
document.body.classList.toggle("force-fixed-tree", hasFixedTree);
|
||||||
|
}, [ note ]);
|
||||||
|
|
||||||
|
// Handle toast notifications.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron()) return;
|
||||||
|
const { ipcRenderer } = dynamicRequire("electron");
|
||||||
|
const listener = () => {
|
||||||
|
toast.closePersistent("printing");
|
||||||
|
};
|
||||||
|
ipcRenderer.on("print-done", listener);
|
||||||
|
return () => ipcRenderer.off("print-done", listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
|
||||||
|
if (!noteContext?.isActive()) return;
|
||||||
|
callback(parentComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
useTriliumEvent("executeWithTypeWidget", ({ resolve, ntxId: eventNtxId }) => {
|
||||||
|
if (eventNtxId !== ntxId || !activeNoteType || !containerRef.current) return;
|
||||||
|
|
||||||
|
const classNameToSearch = TYPE_MAPPINGS[activeNoteType].className;
|
||||||
|
const componentEl = containerRef.current.querySelector<HTMLElement>(`.${classNameToSearch}`);
|
||||||
|
if (!componentEl) return;
|
||||||
|
|
||||||
|
const component = glob.getComponentByEl(componentEl);
|
||||||
|
resolve(component);
|
||||||
|
});
|
||||||
|
|
||||||
|
useTriliumEvent("printActiveNote", () => {
|
||||||
|
if (!noteContext?.isActive() || !note) return;
|
||||||
|
|
||||||
|
toast.showPersistent({
|
||||||
|
icon: "bx bx-loader-circle bx-spin",
|
||||||
|
message: t("note_detail.printing"),
|
||||||
|
id: "printing"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isElectron()) {
|
||||||
|
const { ipcRenderer } = dynamicRequire("electron");
|
||||||
|
ipcRenderer.send("print-note", {
|
||||||
|
notePath: noteContext.notePath
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = `?print#${noteContext.notePath}`;
|
||||||
|
iframe.className = "print-iframe";
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
iframe.onload = () => {
|
||||||
|
if (!iframe.contentWindow) {
|
||||||
|
toast.closePersistent("printing");
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.contentWindow.addEventListener("note-ready", () => {
|
||||||
|
toast.closePersistent("printing");
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useTriliumEvent("exportAsPdf", () => {
|
||||||
|
if (!noteContext?.isActive() || !note) return;
|
||||||
|
toast.showPersistent({
|
||||||
|
icon: "bx bx-loader-circle bx-spin",
|
||||||
|
message: t("note_detail.printing_pdf"),
|
||||||
|
id: "printing"
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ipcRenderer } = dynamicRequire("electron");
|
||||||
|
ipcRenderer.send("export-as-pdf", {
|
||||||
|
title: note.title,
|
||||||
|
notePath: noteContext.notePath,
|
||||||
|
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||||
|
landscape: note.hasAttribute("label", "printLandscape")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class={`note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||||
|
>
|
||||||
|
{Object.entries(noteTypesToRender.current).map(([ itemType, Element ]) => {
|
||||||
|
return <NoteDetailWrapper
|
||||||
|
Element={Element}
|
||||||
|
key={itemType}
|
||||||
|
type={itemType as ExtendedNoteType}
|
||||||
|
isVisible={type === itemType}
|
||||||
|
isFullHeight={isFullHeight}
|
||||||
|
props={props}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a single note type widget, in order to keep it in the DOM even after the user has switched away to another note type. This allows faster loading of the same note type again. The properties are cached, so that they are updated only
|
||||||
|
* while the widget is visible, to avoid rendering in the background. When not visible, the DOM element is simply hidden.
|
||||||
|
*/
|
||||||
|
function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { Element: (props: TypeWidgetProps) => VNode, type: ExtendedNoteType, isVisible: boolean, isFullHeight: boolean, props: TypeWidgetProps }) {
|
||||||
|
const [ cachedProps, setCachedProps ] = useState(props);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
setCachedProps(props);
|
||||||
|
} else {
|
||||||
|
// Do nothing, keep the old props.
|
||||||
|
}
|
||||||
|
}, [ props, isVisible ]);
|
||||||
|
|
||||||
|
const typeMapping = TYPE_MAPPINGS[type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""} ${isVisible ? "visible" : "hidden-ext"}`}
|
||||||
|
style={{
|
||||||
|
height: isFullHeight ? "100%" : ""
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ <Element {...cachedProps} /> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manages both note changes and changes to the widget type, which are asynchronous. */
|
||||||
|
function useNoteInfo() {
|
||||||
|
const { note: actualNote, noteContext, parentComponent } = useNoteContext();
|
||||||
|
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||||
|
const [ type, setType ] = useState<ExtendedNoteType>();
|
||||||
|
const [ mime, setMime ] = useState<string>();
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
getWidgetType(actualNote, noteContext).then(type => {
|
||||||
|
setNote(actualNote);
|
||||||
|
setType(type);
|
||||||
|
setMime(actualNote?.mime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, [ actualNote, noteContext, noteContext?.viewScope ]);
|
||||||
|
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
|
||||||
|
if (eventNoteContext?.ntxId !== noteContext?.ntxId) return;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
useTriliumEvent("noteTypeMimeChanged", refresh);
|
||||||
|
|
||||||
|
return { note, type, mime, noteContext, parentComponent };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCorrespondingWidget(type: ExtendedNoteType): Promise<null | TypeWidget> {
|
||||||
|
const correspondingType = TYPE_MAPPINGS[type].view;
|
||||||
|
if (!correspondingType) return null;
|
||||||
|
|
||||||
|
const result = await correspondingType();
|
||||||
|
|
||||||
|
if ("default" in result) {
|
||||||
|
return result.default;
|
||||||
|
} else if (isValidElement(result)) {
|
||||||
|
// Direct VNode provided.
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWidgetType(note: FNote | null | undefined, noteContext: NoteContext | undefined): Promise<ExtendedNoteType | undefined> {
|
||||||
|
if (!noteContext) return undefined;
|
||||||
|
if (!note) {
|
||||||
|
// If the note is null, then it's a new tab. If it's undefined, then it's not loaded yet.
|
||||||
|
return note === null ? "empty" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = note.type;
|
||||||
|
let resultingType: ExtendedNoteType;
|
||||||
|
|
||||||
|
if (noteContext?.viewScope?.viewMode === "source") {
|
||||||
|
resultingType = "readOnlyCode";
|
||||||
|
} else if (noteContext?.viewScope && noteContext.viewScope.viewMode === "attachments") {
|
||||||
|
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||||
|
} else if (type === "text" && (await noteContext?.isReadOnly())) {
|
||||||
|
resultingType = "readOnlyText";
|
||||||
|
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
|
||||||
|
resultingType = "readOnlyCode";
|
||||||
|
} else if (type === "text") {
|
||||||
|
resultingType = "editableText";
|
||||||
|
} else if (type === "code") {
|
||||||
|
resultingType = "editableCode";
|
||||||
|
} else if (type === "launcher") {
|
||||||
|
resultingType = "doc";
|
||||||
|
} else {
|
||||||
|
resultingType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {
|
||||||
|
resultingType = "protectedSession";
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFullHeight(noteContext: NoteContext | undefined, type: ExtendedNoteType | undefined) {
|
||||||
|
if (!noteContext) return false;
|
||||||
|
|
||||||
|
// https://github.com/zadam/trilium/issues/2522
|
||||||
|
const isBackendNote = noteContext?.noteId === "_backendLog";
|
||||||
|
const isSqlNote = noteContext.note?.mime === "text/x-sqlite;schema=trilium";
|
||||||
|
const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
|
||||||
|
return (!noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|
||||||
|
|| noteContext?.viewScope?.viewMode === "attachments"
|
||||||
|
|| isBackendNote;
|
||||||
|
}
|
||||||
91
apps/client/src/widgets/PromotedAttributes.css
Normal file
91
apps/client/src/widgets/PromotedAttributes.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
body.mobile .promoted-attributes-widget {
|
||||||
|
/* https://github.com/zadam/trilium/issues/4468 */
|
||||||
|
flex-shrink: 0.4;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component.promoted-attributes-widget {
|
||||||
|
contain: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attributes-container {
|
||||||
|
margin: 0 1.5em;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
.promoted-attribute-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px;
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
.promoted-attribute-cell > label {
|
||||||
|
user-select: none;
|
||||||
|
font-weight: bold;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.promoted-attribute-cell > * {
|
||||||
|
display: table-cell;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell div.input-group {
|
||||||
|
margin-inline-start: 10px;
|
||||||
|
display: flex;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.promoted-attribute-cell strong {
|
||||||
|
word-break:keep-all;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="checkbox"] {
|
||||||
|
width: 22px !important;
|
||||||
|
flex-grow: 0;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore default apperance */
|
||||||
|
.promoted-attribute-cell input[type="number"],
|
||||||
|
.promoted-attribute-cell input[type="checkbox"] {
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 2px;
|
||||||
|
appearance: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 25% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
inset-inline-start: 0px;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
120
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
120
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import "./PromotedAttributes.css";
|
||||||
|
import { useNoteContext, useNoteLabel } from "./react/hooks";
|
||||||
|
import { Attribute } from "../services/attribute_parser";
|
||||||
|
import { ComponentChild } from "preact";
|
||||||
|
import FAttribute from "../entities/fattribute";
|
||||||
|
import { t } from "../services/i18n";
|
||||||
|
import ActionButton from "./react/ActionButton";
|
||||||
|
|
||||||
|
export default function PromotedAttributes() {
|
||||||
|
const { note } = useNoteContext();
|
||||||
|
const [ promotedAttributes, setPromotedAttributes ] = useState<ComponentChild[]>();
|
||||||
|
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!note) {
|
||||||
|
setPromotedAttributes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||||
|
const ownedAttributes = note.getOwnedAttributes();
|
||||||
|
// attrs are not resorted if position changes after the initial load
|
||||||
|
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
|
||||||
|
// the order of attributes is important as well
|
||||||
|
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
let promotedAttributes: ComponentChild[] = [];
|
||||||
|
for (const definitionAttr of promotedDefAttrs) {
|
||||||
|
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
||||||
|
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
||||||
|
|
||||||
|
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
||||||
|
|
||||||
|
if (valueAttrs.length === 0) {
|
||||||
|
valueAttrs.push({
|
||||||
|
attributeId: "",
|
||||||
|
type: valueType,
|
||||||
|
name: valueName,
|
||||||
|
value: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definitionAttr.getDefinition().multiplicity === "single") {
|
||||||
|
valueAttrs = valueAttrs.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const valueAttr of valueAttrs) {
|
||||||
|
promotedAttributes.push(<PromotedAttributeCell
|
||||||
|
noteId={note.noteId}
|
||||||
|
definitionAttr={definitionAttr}
|
||||||
|
valueAttr={valueAttr} valueName={valueName} />)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPromotedAttributes(promotedAttributes);
|
||||||
|
console.log("Got ", promotedAttributes);
|
||||||
|
}, [ note ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="promoted-attributes-widget">
|
||||||
|
{viewType !== "table" && (
|
||||||
|
<div className="promoted-attributes-container">
|
||||||
|
{promotedAttributes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromotedAttributeCell({ noteId, definitionAttr, valueAttr, valueName }: {
|
||||||
|
noteId: string;
|
||||||
|
definitionAttr: FAttribute;
|
||||||
|
valueAttr: Attribute;
|
||||||
|
valueName: string;
|
||||||
|
}) {
|
||||||
|
const definition = definitionAttr.getDefinition();
|
||||||
|
const id = `value-${valueAttr.attributeId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="promoted-attribute-cell">
|
||||||
|
<label
|
||||||
|
for={id}
|
||||||
|
>{definition.promotedAlias ?? valueName}</label>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
className="form-control promoted-attribute-input"
|
||||||
|
tabindex={200 + definitionAttr.position}
|
||||||
|
id={id}
|
||||||
|
// if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
||||||
|
data-attribute-id={valueAttr.noteId === noteId ? valueAttr.attributeId ?? "" : ""}
|
||||||
|
data-attribute-type={valueAttr.type}
|
||||||
|
data-attribute-name={valueAttr.name}
|
||||||
|
value={valueAttr.value}
|
||||||
|
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div />
|
||||||
|
|
||||||
|
{definition.multiplicity === "multi" && (
|
||||||
|
<td className="multiplicity">
|
||||||
|
<ActionButton
|
||||||
|
icon="bx bx-plus"
|
||||||
|
className="pointer tn-tool-button"
|
||||||
|
text={t("promoted_attributes.add_new_attribute")}
|
||||||
|
noIconActionClass
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon="bx bx-trash"
|
||||||
|
className="pointer tn-tool-button"
|
||||||
|
text={t("promoted_attributes.remove_this_attribute")}
|
||||||
|
noIconActionClass
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { t } from "../services/i18n.js";
|
||||||
import toastService from "../services/toast.js";
|
import toastService from "../services/toast.js";
|
||||||
import { renderReactWidget } from "./react/react_utils.jsx";
|
import { renderReactWidget } from "./react/react_utils.jsx";
|
||||||
import { EventNames, EventData } from "../components/app_context.js";
|
|
||||||
import { Handler } from "leaflet";
|
|
||||||
|
|
||||||
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
|
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
|
||||||
protected attrs: Record<string, string>;
|
protected attrs: Record<string, string>;
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,8 +58,6 @@ export async function changeEvent(note: FNote, { startDate, endDate, startTime,
|
|||||||
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
|
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
|
||||||
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
|
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
|
||||||
|
|
||||||
if (startTime && endTime) {
|
|
||||||
setAttribute(note, "label", startAttribute, startTime);
|
setAttribute(note, "label", startAttribute, startTime);
|
||||||
setAttribute(note, "label", endAttribute, endTime);
|
setAttribute(note, "label", endAttribute, endTime);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Calendar from "./calendar";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||||
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons";
|
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
||||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||||
import dialog from "../../../services/dialog";
|
import dialog from "../../../services/dialog";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Map from "./map";
|
import Map from "./map";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { ViewModeProps } from "../interface";
|
import { ViewModeProps } from "../interface";
|
||||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks";
|
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||||
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
|
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.scrolling-container {
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-split.type-code:not(.mime-text-x-sqlite) > .scrolling-container {
|
||||||
|
background-color: var(--code-background-color);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { CommandListenerData, EventData, EventNames } from "../../component
|
|||||||
import type NoteContext from "../../components/note_context.js";
|
import type NoteContext from "../../components/note_context.js";
|
||||||
import type BasicWidget from "../basic_widget.js";
|
import type BasicWidget from "../basic_widget.js";
|
||||||
import Container from "./container.js";
|
import Container from "./container.js";
|
||||||
|
import "./scrolling_container.css";
|
||||||
|
|
||||||
export default class ScrollingContainer extends Container<BasicWidget> {
|
export default class ScrollingContainer extends Container<BasicWidget> {
|
||||||
|
|
||||||
@@ -11,9 +12,6 @@ export default class ScrollingContainer extends Container<BasicWidget> {
|
|||||||
super();
|
super();
|
||||||
|
|
||||||
this.class("scrolling-container");
|
this.class("scrolling-container");
|
||||||
this.css("overflow", "auto");
|
|
||||||
this.css("scroll-behavior", "smooth");
|
|
||||||
this.css("position", "relative");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
|
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete";
|
|||||||
import { useRef, useState, useEffect } from "preact/hooks";
|
import { useRef, useState, useEffect } from "preact/hooks";
|
||||||
import tree from "../../services/tree";
|
import tree from "../../services/tree";
|
||||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||||
import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
|
|
||||||
import { logError } from "../../services/ws";
|
import { logError } from "../../services/ws";
|
||||||
import FormGroup from "../react/FormGroup.js";
|
import FormGroup from "../react/FormGroup.js";
|
||||||
import { refToJQuerySelector } from "../react/react_utils";
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
@@ -14,29 +13,32 @@ import { useTriliumEvent } from "../react/hooks";
|
|||||||
|
|
||||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||||
|
|
||||||
|
export interface AddLinkOpts {
|
||||||
|
text: string;
|
||||||
|
hasSelection: boolean;
|
||||||
|
addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AddLinkDialog() {
|
export default function AddLinkDialog() {
|
||||||
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
|
const [ opts, setOpts ] = useState<AddLinkOpts>();
|
||||||
const initialText = useRef<string>();
|
|
||||||
const [ linkTitle, setLinkTitle ] = useState("");
|
const [ linkTitle, setLinkTitle ] = useState("");
|
||||||
const hasSelection = textTypeWidget?.hasSelection();
|
const [ linkType, setLinkType ] = useState<LinkType>();
|
||||||
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
|
|
||||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
const hasSubmittedRef = useRef(false);
|
const hasSubmittedRef = useRef(false);
|
||||||
|
|
||||||
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => {
|
useTriliumEvent("showAddLinkDialog", opts => {
|
||||||
setTextTypeWidget(textTypeWidget);
|
setOpts(opts);
|
||||||
initialText.current = text;
|
|
||||||
setShown(true);
|
setShown(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasSelection) {
|
if (opts?.hasSelection) {
|
||||||
setLinkType("hyper-link");
|
setLinkType("hyper-link");
|
||||||
} else {
|
} else {
|
||||||
setLinkType("reference-link");
|
setLinkType("reference-link");
|
||||||
}
|
}
|
||||||
}, [ hasSelection ])
|
}, [ opts ]);
|
||||||
|
|
||||||
async function setDefaultLinkTitle(noteId: string) {
|
async function setDefaultLinkTitle(noteId: string) {
|
||||||
const noteTitle = await tree.getNoteTitle(noteId);
|
const noteTitle = await tree.getNoteTitle(noteId);
|
||||||
@@ -71,10 +73,10 @@ export default function AddLinkDialog() {
|
|||||||
|
|
||||||
function onShown() {
|
function onShown() {
|
||||||
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
|
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
|
||||||
if (!initialText.current) {
|
if (!opts?.text) {
|
||||||
note_autocomplete.showRecentNotes($autocompleteEl);
|
note_autocomplete.showRecentNotes($autocompleteEl);
|
||||||
} else {
|
} else {
|
||||||
note_autocomplete.setText($autocompleteEl, initialText.current);
|
note_autocomplete.setText($autocompleteEl, opts.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// to be able to quickly remove entered text
|
// to be able to quickly remove entered text
|
||||||
@@ -108,15 +110,15 @@ export default function AddLinkDialog() {
|
|||||||
onShown={onShown}
|
onShown={onShown}
|
||||||
onHidden={() => {
|
onHidden={() => {
|
||||||
// Insert the link.
|
// Insert the link.
|
||||||
if (hasSubmittedRef.current && suggestion && textTypeWidget) {
|
if (hasSubmittedRef.current && suggestion && opts) {
|
||||||
hasSubmittedRef.current = false;
|
hasSubmittedRef.current = false;
|
||||||
|
|
||||||
if (suggestion.notePath) {
|
if (suggestion.notePath) {
|
||||||
// Handle note link
|
// Handle note link
|
||||||
textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||||
} else if (suggestion.externalLink) {
|
} else if (suggestion.externalLink) {
|
||||||
// Handle external link
|
// Handle external link
|
||||||
textTypeWidget.addLink(suggestion.externalLink, linkTitle, true);
|
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export default function AddLinkDialog() {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
{!hasSelection && (
|
{!opts?.hasSelection && (
|
||||||
<div className="add-link-title-settings">
|
<div className="add-link-title-settings">
|
||||||
{(linkType !== "external-link") && (
|
{(linkType !== "external-link") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import Button from "../react/Button";
|
|||||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||||
import tree from "../../services/tree";
|
import tree from "../../services/tree";
|
||||||
import froca from "../../services/froca";
|
import froca from "../../services/froca";
|
||||||
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
|
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||||
|
|
||||||
|
export interface IncludeNoteOpts {
|
||||||
|
editorApi: CKEditorApi;
|
||||||
|
}
|
||||||
|
|
||||||
export default function IncludeNoteDialog() {
|
export default function IncludeNoteDialog() {
|
||||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
const editorApiRef = useRef<CKEditorApi>(null);
|
||||||
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
||||||
const [boxSize, setBoxSize] = useState("medium");
|
const [boxSize, setBoxSize] = useState<string>("medium");
|
||||||
const [shown, setShown] = useState(false);
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => {
|
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
|
||||||
setTextTypeWidget(textTypeWidget);
|
editorApiRef.current = editorApi;
|
||||||
setShown(true);
|
setShown(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,12 +36,9 @@ export default function IncludeNoteDialog() {
|
|||||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||||
onHidden={() => setShown(false)}
|
onHidden={() => setShown(false)}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!suggestion?.notePath || !textTypeWidget) {
|
if (!suggestion?.notePath || !editorApiRef.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShown(false);
|
setShown(false);
|
||||||
includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize);
|
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
|
||||||
}}
|
}}
|
||||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||||
show={shown}
|
show={shown}
|
||||||
@@ -69,7 +70,7 @@ export default function IncludeNoteDialog() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget, boxSize: BoxSize) {
|
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
|
||||||
const noteId = tree.getNoteIdFromUrl(notePath);
|
const noteId = tree.getNoteIdFromUrl(notePath);
|
||||||
if (!noteId) {
|
if (!noteId) {
|
||||||
return;
|
return;
|
||||||
@@ -79,8 +80,8 @@ async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWid
|
|||||||
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
|
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
|
||||||
// there's no benefit to use insert note functionlity for images,
|
// there's no benefit to use insert note functionlity for images,
|
||||||
// so we'll just add an IMG tag
|
// so we'll just add an IMG tag
|
||||||
textTypeWidget.addImage(noteId);
|
editorApi.addImage(noteId);
|
||||||
} else {
|
} else {
|
||||||
textTypeWidget.addIncludeNote(noteId, boxSize);
|
editorApi.addIncludeNote(noteId, boxSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from "preact/hooks";
|
import { useRef, useState } from "preact/hooks";
|
||||||
import appContext from "../../components/app_context";
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import server from "../../services/server";
|
import server from "../../services/server";
|
||||||
import toast from "../../services/toast";
|
import toast from "../../services/toast";
|
||||||
@@ -7,7 +6,11 @@ import utils from "../../services/utils";
|
|||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
import Button from "../react/Button";
|
import Button from "../react/Button";
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||||
|
|
||||||
|
export interface MarkdownImportOpts {
|
||||||
|
editorApi: CKEditorApi;
|
||||||
|
}
|
||||||
|
|
||||||
interface RenderMarkdownResponse {
|
interface RenderMarkdownResponse {
|
||||||
htmlContent: string;
|
htmlContent: string;
|
||||||
@@ -15,18 +18,18 @@ interface RenderMarkdownResponse {
|
|||||||
|
|
||||||
export default function MarkdownImportDialog() {
|
export default function MarkdownImportDialog() {
|
||||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
const editorApiRef = useRef<CKEditorApi>(null);
|
||||||
const [ text, setText ] = useState("");
|
const [ text, setText ] = useState("");
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
|
|
||||||
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
useTriliumEvent("showPasteMarkdownDialog", ({ editorApi }) => {
|
||||||
setTextTypeWidget(textTypeWidget);
|
|
||||||
if (utils.isElectron()) {
|
if (utils.isElectron()) {
|
||||||
const { clipboard } = utils.dynamicRequire("electron");
|
const { clipboard } = utils.dynamicRequire("electron");
|
||||||
const text = clipboard.readText();
|
const text = clipboard.readText();
|
||||||
|
|
||||||
convertMarkdownToHtml(text, textTypeWidget);
|
convertMarkdownToHtml(text, editorApi);
|
||||||
} else {
|
} else {
|
||||||
|
editorApiRef.current = editorApi;
|
||||||
setShown(true);
|
setShown(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -37,8 +40,8 @@ export default function MarkdownImportDialog() {
|
|||||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
||||||
onShown={() => markdownImportTextArea.current?.focus()}
|
onShown={() => markdownImportTextArea.current?.focus()}
|
||||||
onHidden={async () => {
|
onHidden={async () => {
|
||||||
if (textTypeWidget) {
|
if (editorApiRef.current) {
|
||||||
await convertMarkdownToHtml(text, textTypeWidget);
|
await convertMarkdownToHtml(text, editorApiRef.current);
|
||||||
}
|
}
|
||||||
setShown(false);
|
setShown(false);
|
||||||
setText("");
|
setText("");
|
||||||
@@ -59,10 +62,8 @@ export default function MarkdownImportDialog() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: CKEditorApi) {
|
||||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||||
|
textTypeWidget.addHtmlToEditor(htmlContent);
|
||||||
await textTypeWidget.addHtmlToEditor(htmlContent);
|
|
||||||
|
|
||||||
toast.showMessage(t("markdown_import.import_success"));
|
toast.showMessage(t("markdown_import.import_success"));
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { EventNames, EventData } from "../../components/app_context.js";
|
import type { EventNames, EventData } from "../../components/app_context.js";
|
||||||
import NoteContext from "../../components/note_context.js";
|
import NoteContext from "../../components/note_context.js";
|
||||||
import { openDialog } from "../../services/dialog.js";
|
import { openDialog } from "../../services/dialog.js";
|
||||||
import BasicWidget from "../basic_widget.js";
|
import BasicWidget, { ReactWrappedWidget } from "../basic_widget.js";
|
||||||
import Container from "../containers/container.js";
|
import Container from "../containers/container.js";
|
||||||
import TypeWidget from "../type_widgets/type_widget.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`\
|
const TPL = /*html*/`\
|
||||||
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||||
<style>
|
<style>
|
||||||
|
/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */
|
||||||
|
body.popup-editor-open > .modal-backdrop { z-index: 998; }
|
||||||
|
body.popup-editor-open .popup-editor-dialog { z-index: 999; }
|
||||||
|
body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
|
||||||
|
|
||||||
body.desktop .modal.popup-editor-dialog .modal-dialog {
|
body.desktop .modal.popup-editor-dialog .modal-dialog {
|
||||||
max-width: 75vw;
|
max-width: 75vw;
|
||||||
}
|
}
|
||||||
@@ -137,11 +141,6 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dialog.on("shown.bs.modal", async () => {
|
$dialog.on("shown.bs.modal", async () => {
|
||||||
// Reduce the z-index of modals so that ckeditor popups are properly shown on top of it.
|
|
||||||
// The backdrop instance is not shared so it's OK to make a one-off modification.
|
|
||||||
$("body > .modal-backdrop").css("z-index", "998");
|
|
||||||
$dialog.css("z-index", "999");
|
|
||||||
|
|
||||||
await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext });
|
await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext });
|
||||||
this.setVisibility(true);
|
this.setVisibility(true);
|
||||||
await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId });
|
await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId });
|
||||||
@@ -149,7 +148,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
|||||||
$dialog.on("hidden.bs.modal", () => {
|
$dialog.on("hidden.bs.modal", () => {
|
||||||
const $typeWidgetEl = $dialog.find(".note-detail-printable");
|
const $typeWidgetEl = $dialog.find(".note-detail-printable");
|
||||||
if ($typeWidgetEl.length) {
|
if ($typeWidgetEl.length) {
|
||||||
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget;
|
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
|
||||||
typeWidget.cleanup();
|
typeWidget.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +161,12 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
$bodyItems.fadeIn();
|
$bodyItems.fadeIn();
|
||||||
this.$modalHeader.children().show();
|
this.$modalHeader.children().show();
|
||||||
|
document.body.classList.add("popup-editor-open");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$bodyItems.hide();
|
$bodyItems.hide();
|
||||||
this.$modalHeader.children().hide();
|
this.$modalHeader.children().hide();
|
||||||
|
document.body.classList.remove("popup-editor-open");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,464 +0,0 @@
|
|||||||
import { t } from "../services/i18n.js";
|
|
||||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
|
||||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
|
||||||
import SpacedUpdate from "../services/spaced_update.js";
|
|
||||||
import server from "../services/server.js";
|
|
||||||
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
|
|
||||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
|
||||||
import noteCreateService from "../services/note_create.js";
|
|
||||||
import attributeService from "../services/attributes.js";
|
|
||||||
|
|
||||||
import EmptyTypeWidget from "./type_widgets/empty.js";
|
|
||||||
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
|
|
||||||
import EditableCodeTypeWidget from "./type_widgets/editable_code.js";
|
|
||||||
import FileTypeWidget from "./type_widgets/file.js";
|
|
||||||
import ImageTypeWidget from "./type_widgets/image.js";
|
|
||||||
import RenderTypeWidget from "./type_widgets/render.js";
|
|
||||||
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
|
|
||||||
import CanvasTypeWidget from "./type_widgets/canvas.js";
|
|
||||||
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
|
|
||||||
import BookTypeWidget from "./type_widgets/book.js";
|
|
||||||
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
|
|
||||||
import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js";
|
|
||||||
import NoneTypeWidget from "./type_widgets/none.js";
|
|
||||||
import NoteMapTypeWidget from "./type_widgets/note_map.js";
|
|
||||||
import WebViewTypeWidget from "./type_widgets/web_view.js";
|
|
||||||
import DocTypeWidget from "./type_widgets/doc.js";
|
|
||||||
import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
|
||||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
|
||||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
|
||||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
|
||||||
import utils, { isElectron } from "../services/utils.js";
|
|
||||||
import type { NoteType } from "../entities/fnote.js";
|
|
||||||
import type TypeWidget from "./type_widgets/type_widget.js";
|
|
||||||
import { MermaidTypeWidget } from "./type_widgets/mermaid.js";
|
|
||||||
import AiChatTypeWidget from "./type_widgets/ai_chat.js";
|
|
||||||
import toast from "../services/toast.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="note-detail">
|
|
||||||
<style>
|
|
||||||
.note-detail {
|
|
||||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
|
||||||
font-family: var(--detail-font-family);
|
|
||||||
font-size: var(--detail-font-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.prefers-centered-content .note-detail {
|
|
||||||
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const typeWidgetClasses = {
|
|
||||||
empty: EmptyTypeWidget,
|
|
||||||
editableText: EditableTextTypeWidget,
|
|
||||||
readOnlyText: ReadOnlyTextTypeWidget,
|
|
||||||
editableCode: EditableCodeTypeWidget,
|
|
||||||
readOnlyCode: ReadOnlyCodeTypeWidget,
|
|
||||||
file: FileTypeWidget,
|
|
||||||
image: ImageTypeWidget,
|
|
||||||
search: NoneTypeWidget,
|
|
||||||
render: RenderTypeWidget,
|
|
||||||
relationMap: RelationMapTypeWidget,
|
|
||||||
canvas: CanvasTypeWidget,
|
|
||||||
protectedSession: ProtectedSessionTypeWidget,
|
|
||||||
book: BookTypeWidget,
|
|
||||||
noteMap: NoteMapTypeWidget,
|
|
||||||
webView: WebViewTypeWidget,
|
|
||||||
doc: DocTypeWidget,
|
|
||||||
contentWidget: ContentWidgetTypeWidget,
|
|
||||||
attachmentDetail: AttachmentDetailTypeWidget,
|
|
||||||
attachmentList: AttachmentListTypeWidget,
|
|
||||||
mindMap: MindMapWidget,
|
|
||||||
aiChat: AiChatTypeWidget,
|
|
||||||
|
|
||||||
// Split type editors
|
|
||||||
mermaid: MermaidTypeWidget
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
|
||||||
* for protected session or attachment information.
|
|
||||||
*/
|
|
||||||
type ExtendedNoteType =
|
|
||||||
| Exclude<NoteType, "launcher" | "text" | "code">
|
|
||||||
| "empty"
|
|
||||||
| "readOnlyCode"
|
|
||||||
| "readOnlyText"
|
|
||||||
| "editableText"
|
|
||||||
| "editableCode"
|
|
||||||
| "attachmentDetail"
|
|
||||||
| "attachmentList"
|
|
||||||
| "protectedSession"
|
|
||||||
| "aiChat";
|
|
||||||
|
|
||||||
export default class NoteDetailWidget extends NoteContextAwareWidget {
|
|
||||||
|
|
||||||
private typeWidgets: Record<string, TypeWidget>;
|
|
||||||
private spacedUpdate: SpacedUpdate;
|
|
||||||
private type?: ExtendedNoteType;
|
|
||||||
private mime?: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.typeWidgets = {};
|
|
||||||
|
|
||||||
this.spacedUpdate = new SpacedUpdate(async () => {
|
|
||||||
if (!this.noteContext) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { note } = this.noteContext;
|
|
||||||
if (!note) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { noteId } = note;
|
|
||||||
|
|
||||||
const data = await this.getTypeWidget().getData();
|
|
||||||
|
|
||||||
// for read only notes
|
|
||||||
if (data === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
protectedSessionHolder.touchProtectedSessionIfNecessary(note);
|
|
||||||
|
|
||||||
await server.put(`notes/${noteId}/data`, data, this.componentId);
|
|
||||||
|
|
||||||
this.getTypeWidget().dataSaved();
|
|
||||||
});
|
|
||||||
|
|
||||||
appContext.addBeforeUnloadListener(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
isEnabled() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.contentSized();
|
|
||||||
|
|
||||||
if (utils.isElectron()) {
|
|
||||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
|
||||||
ipcRenderer.on("print-done", () => {
|
|
||||||
toast.closePersistent("printing");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refresh() {
|
|
||||||
this.type = await this.getWidgetType();
|
|
||||||
this.mime = this.note?.mime;
|
|
||||||
|
|
||||||
if (!(this.type in this.typeWidgets)) {
|
|
||||||
const clazz = typeWidgetClasses[this.type];
|
|
||||||
|
|
||||||
if (!clazz) {
|
|
||||||
throw new Error(`Cannot find type widget for type '${this.type}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeWidget = (this.typeWidgets[this.type] = new clazz());
|
|
||||||
typeWidget.spacedUpdate = this.spacedUpdate;
|
|
||||||
typeWidget.setParent(this);
|
|
||||||
|
|
||||||
if (this.noteContext) {
|
|
||||||
typeWidget.setNoteContextEvent({ noteContext: this.noteContext });
|
|
||||||
}
|
|
||||||
const $renderedWidget = typeWidget.render();
|
|
||||||
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
|
|
||||||
|
|
||||||
this.$widget.append($renderedWidget);
|
|
||||||
|
|
||||||
if (this.noteContext) {
|
|
||||||
await typeWidget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is happening in update(), so note has been already set, and we need to reflect this
|
|
||||||
if (this.noteContext) {
|
|
||||||
await typeWidget.handleEvent("noteSwitched", {
|
|
||||||
noteContext: this.noteContext,
|
|
||||||
notePath: this.noteContext.notePath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.child(typeWidget);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.checkFullHeight();
|
|
||||||
|
|
||||||
if (utils.isMobile()) {
|
|
||||||
const hasFixedTree = this.noteContext?.hoistedNoteId === "_lbMobileRoot";
|
|
||||||
$("body").toggleClass("force-fixed-tree", hasFixedTree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sets full height of container that contains note content for a subset of note-types
|
|
||||||
*/
|
|
||||||
checkFullHeight() {
|
|
||||||
// https://github.com/zadam/trilium/issues/2522
|
|
||||||
const isBackendNote = this.noteContext?.noteId === "_backendLog";
|
|
||||||
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
|
|
||||||
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file", "aiChat"].includes(this.type ?? "");
|
|
||||||
const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|
|
||||||
|| this.noteContext?.viewScope?.viewMode === "attachments"
|
|
||||||
|| isBackendNote;
|
|
||||||
|
|
||||||
this.$widget.toggleClass("full-height", isFullHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTypeWidget() {
|
|
||||||
if (!this.type || !this.typeWidgets[this.type]) {
|
|
||||||
throw new Error(t(`note_detail.could_not_find_typewidget`, { type: this.type }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.typeWidgets[this.type];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWidgetType(): Promise<ExtendedNoteType> {
|
|
||||||
const note = this.note;
|
|
||||||
if (!note) {
|
|
||||||
return "empty";
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = note.type;
|
|
||||||
let resultingType: ExtendedNoteType;
|
|
||||||
const viewScope = this.noteContext?.viewScope;
|
|
||||||
|
|
||||||
if (viewScope?.viewMode === "source") {
|
|
||||||
resultingType = "readOnlyCode";
|
|
||||||
} else if (viewScope && viewScope.viewMode === "attachments") {
|
|
||||||
resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
|
||||||
} else if (type === "text" && (await this.noteContext?.isReadOnly())) {
|
|
||||||
resultingType = "readOnlyText";
|
|
||||||
} else if ((type === "code" || type === "mermaid") && (await this.noteContext?.isReadOnly())) {
|
|
||||||
resultingType = "readOnlyCode";
|
|
||||||
} else if (type === "text") {
|
|
||||||
resultingType = "editableText";
|
|
||||||
} else if (type === "code") {
|
|
||||||
resultingType = "editableCode";
|
|
||||||
} else if (type === "launcher") {
|
|
||||||
resultingType = "doc";
|
|
||||||
} else {
|
|
||||||
resultingType = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
|
||||||
resultingType = "protectedSession";
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultingType;
|
|
||||||
}
|
|
||||||
|
|
||||||
async focusOnDetailEvent({ ntxId }: EventData<"focusOnDetail">) {
|
|
||||||
if (this.noteContext?.ntxId !== ntxId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refresh();
|
|
||||||
const widget = this.getTypeWidget();
|
|
||||||
await widget.initialized;
|
|
||||||
widget.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async scrollToEndEvent({ ntxId }: EventData<"scrollToEnd">) {
|
|
||||||
if (this.noteContext?.ntxId !== ntxId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refresh();
|
|
||||||
const widget = this.getTypeWidget();
|
|
||||||
await widget.initialized;
|
|
||||||
|
|
||||||
if (widget.scrollToEnd) {
|
|
||||||
widget.scrollToEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) {
|
|
||||||
if (this.isNoteContext(noteContext.ntxId)) {
|
|
||||||
await this.spacedUpdate.updateNowIfNecessary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) {
|
|
||||||
if (this.isNoteContext(ntxIds)) {
|
|
||||||
await this.spacedUpdate.updateNowIfNecessary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
|
|
||||||
if (this.isNoteContext(params.ntxId)) {
|
|
||||||
// make sure that script is saved before running it #4028
|
|
||||||
await this.spacedUpdate.updateNowIfNecessary();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.parent?.triggerCommand("runActiveNote", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async printActiveNoteEvent() {
|
|
||||||
if (!this.noteContext?.isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.showPersistent({
|
|
||||||
icon: "bx bx-loader-circle bx-spin",
|
|
||||||
message: t("note_detail.printing"),
|
|
||||||
id: "printing"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isElectron()) {
|
|
||||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
|
||||||
ipcRenderer.send("print-note", {
|
|
||||||
notePath: this.notePath
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.src = `?print#${this.notePath}`;
|
|
||||||
iframe.className = "print-iframe";
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
iframe.onload = () => {
|
|
||||||
if (!iframe.contentWindow) {
|
|
||||||
toast.closePersistent("printing");
|
|
||||||
document.body.removeChild(iframe);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe.contentWindow.addEventListener("note-ready", () => {
|
|
||||||
toast.closePersistent("printing");
|
|
||||||
iframe.contentWindow?.print();
|
|
||||||
document.body.removeChild(iframe);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportAsPdfEvent() {
|
|
||||||
if (!this.noteContext?.isActive() || !this.note || !this.notePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.showPersistent({
|
|
||||||
icon: "bx bx-loader-circle bx-spin",
|
|
||||||
message: t("note_detail.printing_pdf"),
|
|
||||||
id: "printing"
|
|
||||||
});
|
|
||||||
|
|
||||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
|
||||||
ipcRenderer.send("export-as-pdf", {
|
|
||||||
title: this.note.title,
|
|
||||||
notePath: this.notePath,
|
|
||||||
pageSize: this.note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
|
||||||
landscape: this.note.hasAttribute("label", "printLandscape")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) {
|
|
||||||
if (this.isNoteContext(ntxId)) {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
|
||||||
// we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged
|
|
||||||
// globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple
|
|
||||||
// times if the same note is open in several tabs.
|
|
||||||
|
|
||||||
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId, this.componentId)) {
|
|
||||||
// probably incorrect event
|
|
||||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
|
||||||
// FIXME: create a separate event to force hierarchical refresh
|
|
||||||
|
|
||||||
// this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree
|
|
||||||
// to avoid the problem in #3365
|
|
||||||
this.handleEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
|
||||||
} else if (this.noteId && loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== (await this.getWidgetType()) || this.mime !== this.note?.mime)) {
|
|
||||||
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
|
|
||||||
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
|
||||||
} else {
|
|
||||||
const attrs = loadResults.getAttributeRows();
|
|
||||||
|
|
||||||
const label = attrs.find(
|
|
||||||
(attr) =>
|
|
||||||
attr.type === "label" &&
|
|
||||||
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
|
|
||||||
attributeService.isAffecting(attr, this.note)
|
|
||||||
);
|
|
||||||
|
|
||||||
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note));
|
|
||||||
|
|
||||||
if (this.noteId && (label || relation)) {
|
|
||||||
// probably incorrect event
|
|
||||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
|
||||||
this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeUnloadEvent() {
|
|
||||||
return this.spacedUpdate.isAllSavedAndTriggerUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
readOnlyTemporarilyDisabledEvent({ noteContext }: EventData<"readOnlyTemporarilyDisabled">) {
|
|
||||||
if (this.isNoteContext(noteContext.ntxId)) {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeInActiveNoteDetailWidgetEvent({ callback }: EventData<"executeInActiveNoteDetailWidget">) {
|
|
||||||
if (!this.isActiveNoteContext()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.initialized;
|
|
||||||
|
|
||||||
callback(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cutIntoNoteCommand() {
|
|
||||||
const note = appContext.tabManager.getActiveContextNote();
|
|
||||||
|
|
||||||
if (!note) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// without await as this otherwise causes deadlock through component mutex
|
|
||||||
const parentNotePath = appContext.tabManager.getActiveContextNotePath();
|
|
||||||
if (this.noteContext && parentNotePath) {
|
|
||||||
noteCreateService.createNote(parentNotePath, {
|
|
||||||
isProtected: note.isProtected,
|
|
||||||
saveSelection: true,
|
|
||||||
textEditor: await this.noteContext.getTextEditor()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// used by cutToNote in CKEditor build
|
|
||||||
async saveNoteDetailNowCommand() {
|
|
||||||
await this.spacedUpdate.updateNowIfNecessary();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderActiveNoteEvent() {
|
|
||||||
if (this.noteContext?.isActive()) {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeWithTypeWidgetEvent({ resolve, ntxId }: EventData<"executeWithTypeWidget">) {
|
|
||||||
if (!this.isNoteContext(ntxId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.initialized;
|
|
||||||
|
|
||||||
await this.getWidgetType();
|
|
||||||
|
|
||||||
resolve(this.getTypeWidget());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
// Prevent user from navigating away if the spaced update is not done.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
|
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||||
|
appContext.addBeforeUnloadListener(listener);
|
||||||
|
return () => appContext.removeBeforeUnloadListener(listener);
|
||||||
}, []);
|
}, []);
|
||||||
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
|
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
|
||||||
|
|
||||||
|
|||||||
143
apps/client/src/widgets/note_types.tsx
Normal file
143
apps/client/src/widgets/note_types.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @module
|
||||||
|
* Contains the definitions for all the note types supported by the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NoteType } from "@triliumnext/commons";
|
||||||
|
import { VNode, type JSX } from "preact";
|
||||||
|
import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||||
|
* for protected session or attachment information.
|
||||||
|
*/
|
||||||
|
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "aiChat";
|
||||||
|
|
||||||
|
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element);
|
||||||
|
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
|
||||||
|
|
||||||
|
interface NoteTypeMapping {
|
||||||
|
view: NoteTypeView;
|
||||||
|
printable?: boolean;
|
||||||
|
/** The class name to assign to the note type wrapper */
|
||||||
|
className: string;
|
||||||
|
isFullHeight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||||
|
empty: {
|
||||||
|
view: () => import("./type_widgets/Empty"),
|
||||||
|
className: "note-detail-empty",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
doc: {
|
||||||
|
view: () => import("./type_widgets/Doc"),
|
||||||
|
className: "note-detail-doc",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
view: () => (props: TypeWidgetProps) => <></>,
|
||||||
|
className: "note-detail-none",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
protectedSession: {
|
||||||
|
view: () => import("./type_widgets/ProtectedSession"),
|
||||||
|
className: "protected-session-password-component"
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
view: () => import("./type_widgets/Book"),
|
||||||
|
className: "note-detail-book",
|
||||||
|
printable: true,
|
||||||
|
},
|
||||||
|
contentWidget: {
|
||||||
|
view: () => import("./type_widgets/ContentWidget"),
|
||||||
|
className: "note-detail-content-widget",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
webView: {
|
||||||
|
view: () => import("./type_widgets/WebView"),
|
||||||
|
className: "note-detail-web-view",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
view: () => import("./type_widgets/File"),
|
||||||
|
className: "note-detail-file",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
view: () => import("./type_widgets/Image"),
|
||||||
|
className: "note-detail-image",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
readOnlyCode: {
|
||||||
|
view: async () => (await import("./type_widgets/code/Code")).ReadOnlyCode,
|
||||||
|
className: "note-detail-readonly-code",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
editableCode: {
|
||||||
|
view: async () => (await import("./type_widgets/code/Code")).EditableCode,
|
||||||
|
className: "note-detail-code",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
mermaid: {
|
||||||
|
view: () => import("./type_widgets/Mermaid"),
|
||||||
|
className: "note-detail-mermaid",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
mindMap: {
|
||||||
|
view: () => import("./type_widgets/MindMap"),
|
||||||
|
className: "note-detail-mind-map",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
attachmentList: {
|
||||||
|
view: async () => (await import("./type_widgets/Attachment")).AttachmentList,
|
||||||
|
className: "attachment-list",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
attachmentDetail: {
|
||||||
|
view: async () => (await import("./type_widgets/Attachment")).AttachmentDetail,
|
||||||
|
className: "attachment-detail",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
readOnlyText: {
|
||||||
|
view: () => import("./type_widgets/text/ReadOnlyText"),
|
||||||
|
className: "note-detail-readonly-text"
|
||||||
|
},
|
||||||
|
editableText: {
|
||||||
|
view: () => import("./type_widgets/text/EditableText"),
|
||||||
|
className: "note-detail-editable-text",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
render: {
|
||||||
|
view: () => import("./type_widgets/Render"),
|
||||||
|
className: "note-detail-render",
|
||||||
|
printable: true
|
||||||
|
},
|
||||||
|
canvas: {
|
||||||
|
view: () => import("./type_widgets/canvas/Canvas"),
|
||||||
|
className: "note-detail-canvas",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
relationMap: {
|
||||||
|
view: () => import("./type_widgets/relation_map/RelationMap"),
|
||||||
|
className: "note-detail-relation-map",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
noteMap: {
|
||||||
|
view: () => import("./type_widgets/NoteMap"),
|
||||||
|
className: "note-detail-note-map",
|
||||||
|
printable: true,
|
||||||
|
isFullHeight: true
|
||||||
|
},
|
||||||
|
aiChat: {
|
||||||
|
view: () => import("./type_widgets/AiChat"),
|
||||||
|
className: "ai-chat-widget-container",
|
||||||
|
isFullHeight: true
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -52,7 +52,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
|||||||
|
|
||||||
const note = this.noteContext?.note;
|
const note = this.noteContext?.note;
|
||||||
if (!note) {
|
if (!note) {
|
||||||
this.$widget.addClass("bgfx");
|
this.$widget.addClass("bgfx empty-note");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,102 +12,6 @@ import type { Attribute } from "../services/attribute_parser.js";
|
|||||||
import type FAttribute from "../entities/fattribute.js";
|
import type FAttribute from "../entities/fattribute.js";
|
||||||
import type { EventData } from "../components/app_context.js";
|
import type { EventData } from "../components/app_context.js";
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="promoted-attributes-widget">
|
|
||||||
<style>
|
|
||||||
body.mobile .promoted-attributes-widget {
|
|
||||||
/* https://github.com/zadam/trilium/issues/4468 */
|
|
||||||
flex-shrink: 0.4;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attributes-container {
|
|
||||||
margin: 0 1.5em;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 400px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
.promoted-attribute-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 10px;
|
|
||||||
display: table-row;
|
|
||||||
}
|
|
||||||
.promoted-attribute-cell > label {
|
|
||||||
user-select: none;
|
|
||||||
font-weight: bold;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.promoted-attribute-cell > * {
|
|
||||||
display: table-cell;
|
|
||||||
padding: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell div.input-group {
|
|
||||||
margin-inline-start: 10px;
|
|
||||||
display: flex;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
.promoted-attribute-cell strong {
|
|
||||||
word-break:keep-all;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="checkbox"] {
|
|
||||||
width: 22px !important;
|
|
||||||
flex-grow: 0;
|
|
||||||
width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Restore default apperance */
|
|
||||||
.promoted-attribute-cell input[type="number"],
|
|
||||||
.promoted-attribute-cell input[type="checkbox"] {
|
|
||||||
appearance: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"] {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: 2px;
|
|
||||||
appearance: none;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
border-radius: 25% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
|
||||||
border: none;
|
|
||||||
border-radius: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
|
||||||
position: relative;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
inset-inline-start: 0px;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="promoted-attributes-container"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// TODO: Deduplicate
|
// TODO: Deduplicate
|
||||||
interface AttributeResult {
|
interface AttributeResult {
|
||||||
attributeId: string;
|
attributeId: string;
|
||||||
@@ -117,115 +21,17 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
|||||||
|
|
||||||
private $container!: JQuery<HTMLElement>;
|
private $container!: JQuery<HTMLElement>;
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "promotedAttributes";
|
|
||||||
}
|
|
||||||
|
|
||||||
get toggleCommand() {
|
|
||||||
return "toggleRibbonTabPromotedAttributes";
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
doRender() {
|
||||||
this.$widget = $(TPL);
|
this.$widget = $("");
|
||||||
this.contentSized();
|
this.contentSized();
|
||||||
this.$container = this.$widget.find(".promoted-attributes-container");
|
this.$container = this.$widget.find(".promoted-attributes-container");
|
||||||
}
|
}
|
||||||
|
|
||||||
getTitle(note: FNote) {
|
|
||||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
|
||||||
|
|
||||||
if (promotedDefAttrs.length === 0) {
|
|
||||||
return { show: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: true,
|
|
||||||
activate: options.is("promotedAttributesOpenInRibbon"),
|
|
||||||
title: t("promoted_attributes.promoted_attributes"),
|
|
||||||
icon: "bx bx-table"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshWithNote(note: FNote) {
|
|
||||||
this.$container.empty();
|
|
||||||
|
|
||||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
|
||||||
const ownedAttributes = note.getOwnedAttributes();
|
|
||||||
// attrs are not resorted if position changes after the initial load
|
|
||||||
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
|
|
||||||
// the order of attributes is important as well
|
|
||||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
|
||||||
|
|
||||||
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
|
|
||||||
this.toggleInt(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $cells: JQuery<HTMLElement>[] = [];
|
|
||||||
|
|
||||||
for (const definitionAttr of promotedDefAttrs) {
|
|
||||||
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
|
||||||
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
|
||||||
|
|
||||||
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
|
||||||
|
|
||||||
if (valueAttrs.length === 0) {
|
|
||||||
valueAttrs.push({
|
|
||||||
attributeId: "",
|
|
||||||
type: valueType,
|
|
||||||
name: valueName,
|
|
||||||
value: ""
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definitionAttr.getDefinition().multiplicity === "single") {
|
|
||||||
valueAttrs = valueAttrs.slice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const valueAttr of valueAttrs) {
|
|
||||||
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
|
|
||||||
|
|
||||||
if ($cell) {
|
|
||||||
$cells.push($cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// we replace the whole content in one step, so there can't be any race conditions
|
|
||||||
// (previously we saw promoted attributes doubling)
|
|
||||||
this.$container.empty().append(...$cells);
|
|
||||||
this.toggleInt(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
|
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
|
||||||
const definition = definitionAttr.getDefinition();
|
// .on("change", (event) => this.promotedAttributeChanged(event));
|
||||||
const id = `value-${valueAttr.attributeId}`;
|
|
||||||
|
|
||||||
const $input = $("<input>")
|
|
||||||
.prop("tabindex", 200 + definitionAttr.position)
|
|
||||||
.prop("id", id)
|
|
||||||
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
|
||||||
.attr("data-attribute-type", valueAttr.type)
|
|
||||||
.attr("data-attribute-name", valueAttr.name)
|
|
||||||
.prop("value", valueAttr.value)
|
|
||||||
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
|
|
||||||
.addClass("form-control")
|
|
||||||
.addClass("promoted-attribute-input")
|
|
||||||
.on("change", (event) => this.promotedAttributeChanged(event));
|
|
||||||
|
|
||||||
const $actionCell = $("<div>");
|
|
||||||
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
|
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
|
||||||
|
|
||||||
const $wrapper = $('<div class="promoted-attribute-cell">')
|
|
||||||
.append(
|
|
||||||
$("<label>")
|
|
||||||
.prop("for", id)
|
|
||||||
.text(definition.promotedAlias ?? valueName)
|
|
||||||
)
|
|
||||||
.append($("<div>").addClass("input-group").append($input))
|
|
||||||
.append($actionCell)
|
|
||||||
.append($multiplicityCell);
|
|
||||||
|
|
||||||
if (valueAttr.type === "label") {
|
if (valueAttr.type === "label") {
|
||||||
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
|
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
|
||||||
if (definition.labelType === "text") {
|
if (definition.labelType === "text") {
|
||||||
@@ -359,8 +165,6 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
|||||||
|
|
||||||
if (definition.multiplicity === "multi") {
|
if (definition.multiplicity === "multi") {
|
||||||
const $addButton = $("<span>")
|
const $addButton = $("<span>")
|
||||||
.addClass("bx bx-plus pointer tn-tool-button")
|
|
||||||
.prop("title", t("promoted_attributes.add_new_attribute"))
|
|
||||||
.on("click", async () => {
|
.on("click", async () => {
|
||||||
const $new = await this.createPromotedAttributeCell(
|
const $new = await this.createPromotedAttributeCell(
|
||||||
definitionAttr,
|
definitionAttr,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { ComponentChildren } from "preact";
|
|||||||
interface AdmonitionProps {
|
interface AdmonitionProps {
|
||||||
type: "warning" | "note" | "caution";
|
type: "warning" | "note" | "caution";
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`admonition ${type}`} role="alert">
|
<div className={`admonition ${type} ${className}`} role="alert">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,15 +27,16 @@ interface CKEditorOpts {
|
|||||||
onClick?: (e: MouseEvent, pos?: ModelPosition | null) => void;
|
onClick?: (e: MouseEvent, pos?: ModelPosition | null) => void;
|
||||||
onKeyDown?: (e: KeyboardEvent) => void;
|
onKeyDown?: (e: KeyboardEvent) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onInitialized?: (editorInstance: CKTextEditor) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, ...restProps }: CKEditorOpts) {
|
export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, onInitialized, ...restProps }: CKEditorOpts) {
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const textEditorRef = useRef<CKTextEditor>(null);
|
const textEditorRef = useRef<CKTextEditor>(null);
|
||||||
useImperativeHandle(apiRef, () => {
|
useImperativeHandle(apiRef, () => {
|
||||||
return {
|
return {
|
||||||
focus() {
|
focus() {
|
||||||
editorContainerRef.current?.focus();
|
textEditorRef.current?.editing.view.focus();
|
||||||
textEditorRef.current?.model.change((writer) => {
|
textEditorRef.current?.model.change((writer) => {
|
||||||
const documentRoot = textEditorRef.current?.editing.model.document.getRoot();
|
const documentRoot = textEditorRef.current?.editing.model.document.getRoot();
|
||||||
if (documentRoot) {
|
if (documentRoot) {
|
||||||
@@ -83,6 +84,8 @@ export default function CKEditor({ apiRef, currentValue, editor, config, disable
|
|||||||
if (currentValue) {
|
if (currentValue) {
|
||||||
textEditor.setData(currentValue);
|
textEditor.setData(currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onInitialized?.(textEditor);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ interface FormListItemOpts {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
badges?: FormListBadge[];
|
badges?: FormListBadge[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Will indicate the reason why the item is disabled via an icon, when hovered over it. */
|
||||||
|
disabledTooltip?: string;
|
||||||
checked?: boolean | null;
|
checked?: boolean | null;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
container?: boolean;
|
container?: boolean;
|
||||||
@@ -119,21 +121,24 @@ export function FormListItem({ className, icon, value, title, active, disabled,
|
|||||||
<Icon icon={icon} />
|
<Icon icon={icon} />
|
||||||
{description ? (
|
{description ? (
|
||||||
<div>
|
<div>
|
||||||
<FormListContent description={description} {...contentProps} />
|
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormListContent description={description} {...contentProps} />
|
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) {
|
function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick<FormListItemOpts, "children" | "badges" | "description" | "disabled" | "disabledTooltip">) {
|
||||||
return <>
|
return <>
|
||||||
{children}
|
{children}
|
||||||
{badges && badges.map(({ className, text }) => (
|
{badges && badges.map(({ className, text }) => (
|
||||||
<span className={`badge ${className ?? ""}`}>{text}</span>
|
<span className={`badge ${className ?? ""}`}>{text}</span>
|
||||||
))}
|
))}
|
||||||
|
{disabled && disabledTooltip && (
|
||||||
|
<span class="bx bx-info-circle disabled-tooltip" title={disabledTooltip} />
|
||||||
|
)}
|
||||||
{description && <div className="description">{description}</div>}
|
{description && <div className="description">{description}</div>}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
|||||||
interface HelpButtonProps {
|
interface HelpButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
helpPage: string;
|
helpPage: string;
|
||||||
|
title?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
|
export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class={`${className ?? ""} icon-action bx bx-help-circle`}
|
class={`${className ?? ""} icon-action bx bx-help-circle`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openInAppHelpFromUrl(helpPage)}
|
onClick={() => openInAppHelpFromUrl(helpPage)}
|
||||||
title={t("open-help-page")}
|
title={title ?? t("open-help-page")}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
.info-bar-subtle {
|
.info-bar-subtle {
|
||||||
color: var(--muted-text-color);
|
color: var(--muted-text-color);
|
||||||
|
background: var(--main-background-color);
|
||||||
border-bottom: 1px solid var(--main-border-color);
|
border-bottom: 1px solid var(--main-border-color);
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
margin-inline: 10px;
|
padding-inline: 22px;
|
||||||
padding-inline: 12px;
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
import link from "../../services/link";
|
import link, { ViewScope } from "../../services/link";
|
||||||
import { useImperativeSearchHighlighlighting } from "./hooks";
|
import { useImperativeSearchHighlighlighting } from "./hooks";
|
||||||
|
|
||||||
interface NoteLinkOpts {
|
interface NoteLinkOpts {
|
||||||
@@ -11,18 +11,26 @@ interface NoteLinkOpts {
|
|||||||
noPreview?: boolean;
|
noPreview?: boolean;
|
||||||
noTnLink?: boolean;
|
noTnLink?: boolean;
|
||||||
highlightedTokens?: string[] | null | undefined;
|
highlightedTokens?: string[] | null | undefined;
|
||||||
|
// Override the text of the link, otherwise the note title is used.
|
||||||
|
title?: string;
|
||||||
|
viewScope?: ViewScope;
|
||||||
|
noContextMenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) {
|
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
|
||||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
||||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
|
link.createLink(stringifiedNotePath, {
|
||||||
.then(setJqueryEl);
|
title,
|
||||||
}, [ stringifiedNotePath, showNotePath ]);
|
showNotePath,
|
||||||
|
showNoteIcon,
|
||||||
|
viewScope
|
||||||
|
}).then(setJqueryEl);
|
||||||
|
}, [ stringifiedNotePath, showNotePath, title, viewScope ]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current || !jqueryEl) return;
|
if (!ref.current || !jqueryEl) return;
|
||||||
@@ -43,6 +51,10 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
|
|||||||
$linkEl?.addClass("tn-link");
|
$linkEl?.addClass("tn-link");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noContextMenu) {
|
||||||
|
$linkEl?.attr("data-no-context-menu", "true");
|
||||||
|
}
|
||||||
|
|
||||||
if (className) {
|
if (className) {
|
||||||
$linkEl?.addClass(className);
|
$linkEl?.addClass(className);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
icon?: string;
|
||||||
click: () => void;
|
click: () => void;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpacerProps {
|
interface SpacerProps {
|
||||||
@@ -129,13 +130,14 @@ export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: S
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) {
|
export function TouchBarButton({ label, icon, click, enabled, backgroundColor }: ButtonProps) {
|
||||||
const api = useContext(TouchBarContext);
|
const api = useContext(TouchBarContext);
|
||||||
const item = useMemo(() => {
|
const item = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
return new api.TouchBar.TouchBarButton({
|
return new api.TouchBar.TouchBarButton({
|
||||||
label, click, enabled,
|
label, click, enabled,
|
||||||
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
|
icon: icon ? buildIcon(api.nativeImage, icon) : undefined,
|
||||||
|
backgroundColor
|
||||||
});
|
});
|
||||||
}, [ label, icon ]);
|
}, [ label, icon ]);
|
||||||
|
|
||||||
@@ -171,6 +173,32 @@ export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChan
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TouchBarGroup({ children }: { children: ComponentChildren }) {
|
||||||
|
const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote");
|
||||||
|
const items: TouchBarItem[] = [];
|
||||||
|
|
||||||
|
const api: TouchBarContextApi = {
|
||||||
|
TouchBar: remote.TouchBar,
|
||||||
|
nativeImage: remote.nativeImage,
|
||||||
|
addItem: (item) => {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (api) {
|
||||||
|
const item = new api.TouchBar.TouchBarGroup({
|
||||||
|
items: new api.TouchBar({ items })
|
||||||
|
});
|
||||||
|
api.addItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<TouchBarContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
</TouchBarContext.Provider>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function TouchBarSpacer({ size }: SpacerProps) {
|
export function TouchBarSpacer({ size }: SpacerProps) {
|
||||||
const api = useContext(TouchBarContext);
|
const api = useContext(TouchBarContext);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { CSSProperties } from "preact/compat";
|
import { CSSProperties } from "preact/compat";
|
||||||
import { DragData } from "../note_tree";
|
import { DragData } from "../note_tree";
|
||||||
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
|
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
|
||||||
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { ParentComponent } from "./react_utils";
|
import { ParentComponent, refToJQuerySelector } from "./react_utils";
|
||||||
import { RefObject, VNode } from "preact";
|
import { RefObject, VNode } from "preact";
|
||||||
import { Tooltip } from "bootstrap";
|
import { Tooltip } from "bootstrap";
|
||||||
import { ViewMode } from "../../services/link";
|
import { ViewMode, ViewScope } from "../../services/link";
|
||||||
import appContext, { CommandListenerData, EventData, EventNames } from "../../components/app_context";
|
import appContext, { EventData, EventNames } from "../../components/app_context";
|
||||||
import attributes from "../../services/attributes";
|
import attributes from "../../services/attributes";
|
||||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
|
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
|
||||||
import Component from "../../components/component";
|
import Component from "../../components/component";
|
||||||
@@ -21,6 +21,8 @@ import protected_session_holder from "../../services/protected_session_holder";
|
|||||||
import SpacedUpdate from "../../services/spaced_update";
|
import SpacedUpdate from "../../services/spaced_update";
|
||||||
import toast, { ToastOptions } from "../../services/toast";
|
import toast, { ToastOptions } from "../../services/toast";
|
||||||
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
|
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
|
||||||
|
import server from "../../services/server";
|
||||||
|
import { removeIndividualBinding } from "../../services/shortcuts";
|
||||||
|
|
||||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||||
const parentComponent = useContext(ParentComponent);
|
const parentComponent = useContext(ParentComponent);
|
||||||
@@ -75,6 +77,66 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
|
|||||||
return spacedUpdateRef.current;
|
return spacedUpdateRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEditorSpacedUpdate({ note, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
|
||||||
|
note: FNote,
|
||||||
|
noteContext: NoteContext | null | undefined,
|
||||||
|
getData: () => Promise<object | undefined> | object | undefined,
|
||||||
|
onContentChange: (newContent: string) => void,
|
||||||
|
dataSaved?: () => void,
|
||||||
|
updateInterval?: number;
|
||||||
|
}) {
|
||||||
|
const parentComponent = useContext(ParentComponent);
|
||||||
|
const blob = useNoteBlob(note, parentComponent?.componentId);
|
||||||
|
|
||||||
|
const callback = useMemo(() => {
|
||||||
|
return async () => {
|
||||||
|
const data = await getData();
|
||||||
|
|
||||||
|
// for read only notes
|
||||||
|
if (data === undefined) return;
|
||||||
|
|
||||||
|
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||||
|
await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId);
|
||||||
|
|
||||||
|
dataSaved?.();
|
||||||
|
}
|
||||||
|
}, [ note, getData, dataSaved ])
|
||||||
|
const spacedUpdate = useSpacedUpdate(callback);
|
||||||
|
|
||||||
|
// React to note/blob changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blob) return;
|
||||||
|
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
|
||||||
|
}, [ blob ]);
|
||||||
|
|
||||||
|
// React to update interval changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!updateInterval) return;
|
||||||
|
spacedUpdate.setUpdateInterval(updateInterval);
|
||||||
|
}, [ updateInterval ]);
|
||||||
|
|
||||||
|
// Save if needed upon switching tabs.
|
||||||
|
useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => {
|
||||||
|
if (eventNoteContext.ntxId !== noteContext?.ntxId) return;
|
||||||
|
await spacedUpdate.updateNowIfNecessary();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save if needed upon tab closing.
|
||||||
|
useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => {
|
||||||
|
if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return;
|
||||||
|
await spacedUpdate.updateNowIfNecessary();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save if needed upon window/browser closing.
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||||
|
appContext.addBeforeUnloadListener(listener);
|
||||||
|
return () => appContext.removeBeforeUnloadListener(listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return spacedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
||||||
*
|
*
|
||||||
@@ -198,7 +260,7 @@ export function useNoteContext() {
|
|||||||
const [ noteContext, setNoteContext ] = useState<NoteContext>();
|
const [ noteContext, setNoteContext ] = useState<NoteContext>();
|
||||||
const [ notePath, setNotePath ] = useState<string | null | undefined>();
|
const [ notePath, setNotePath ] = useState<string | null | undefined>();
|
||||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||||
const [ , setViewMode ] = useState<ViewMode>();
|
const [ , setViewScope ] = useState<ViewScope>();
|
||||||
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
|
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
|
||||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||||
|
|
||||||
@@ -209,7 +271,7 @@ export function useNoteContext() {
|
|||||||
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
|
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
|
||||||
setNoteContext(noteContext);
|
setNoteContext(noteContext);
|
||||||
setNotePath(noteContext.notePath);
|
setNotePath(noteContext.notePath);
|
||||||
setViewMode(noteContext.viewScope?.viewMode);
|
setViewScope(noteContext.viewScope);
|
||||||
});
|
});
|
||||||
useTriliumEvent("frocaReloaded", () => {
|
useTriliumEvent("frocaReloaded", () => {
|
||||||
setNote(noteContext?.note);
|
setNote(noteContext?.note);
|
||||||
@@ -381,7 +443,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: Filte
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined {
|
export function useNoteBlob(note: FNote | null | undefined, componentId?: string): FBlob | null | undefined {
|
||||||
const [ blob, setBlob ] = useState<FBlob | null>();
|
const [ blob, setBlob ] = useState<FBlob | null>();
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -402,6 +464,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde
|
|||||||
if (loadResults.hasRevisionForNote(note.noteId)) {
|
if (loadResults.hasRevisionForNote(note.noteId)) {
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadResults.isNoteContentReloaded(note.noteId, componentId)) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useDebugValue(note?.noteId);
|
useDebugValue(note?.noteId);
|
||||||
@@ -675,26 +741,6 @@ export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | un
|
|||||||
}, [ containerRef, callback ]);
|
}, [ containerRef, callback ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTouchBar(
|
|
||||||
factory: (context: CommandListenerData<"buildTouchBar"> & { parentComponent: Component | null }) => void,
|
|
||||||
inputs: Inputs
|
|
||||||
) {
|
|
||||||
const parentComponent = useContext(ParentComponent);
|
|
||||||
|
|
||||||
useLegacyImperativeHandlers({
|
|
||||||
buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) {
|
|
||||||
return factory({
|
|
||||||
...context,
|
|
||||||
parentComponent
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
parentComponent?.triggerCommand("refreshTouchBar");
|
|
||||||
}, inputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
|
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
|
||||||
const resizeObserver = useRef<ResizeObserver>(null);
|
const resizeObserver = useRef<ResizeObserver>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -710,6 +756,20 @@ export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => v
|
|||||||
}, [ callback, ref ]);
|
}, [ callback, ref ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", containerRef: RefObject<HTMLElement>, parentComponent: Component | undefined) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parentComponent) return;
|
||||||
|
const $container = refToJQuerySelector(containerRef);
|
||||||
|
const bindingPromise = keyboard_actions.setupActionsForElement(scope, $container, parentComponent);
|
||||||
|
return async () => {
|
||||||
|
const bindings = await bindingPromise;
|
||||||
|
for (const binding of bindings) {
|
||||||
|
removeIndividualBinding(binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that the current note is in read-only mode, while an editing mode is available,
|
* Indicates that the current note is in read-only mode, while an editing mode is available,
|
||||||
* and provides a way to switch to editing mode.
|
* and provides a way to switch to editing mode.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
|||||||
<div className="file-properties-widget">
|
<div className="file-properties-widget">
|
||||||
{note && (
|
{note && (
|
||||||
<table class="file-table">
|
<table class="file-table">
|
||||||
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-nowrap">{t("file_properties.note_id")}:</th>
|
<th class="text-nowrap">{t("file_properties.note_id")}:</th>
|
||||||
<td class="file-note-id">{note.noteId}</td>
|
<td class="file-note-id">{note.noteId}</td>
|
||||||
@@ -70,6 +71,7 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ParentComponent } from "../react/react_utils";
|
|||||||
import { t } from "../../services/i18n"
|
import { t } from "../../services/i18n"
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { useIsNoteReadOnly } from "../react/hooks";
|
import { useIsNoteReadOnly } from "../react/hooks";
|
||||||
|
import { useTriliumOption } from "../react/hooks";
|
||||||
import ActionButton from "../react/ActionButton"
|
import ActionButton from "../react/ActionButton"
|
||||||
import appContext, { CommandNames } from "../../components/app_context";
|
import appContext, { CommandNames } from "../../components/app_context";
|
||||||
import branches from "../../services/branches";
|
import branches from "../../services/branches";
|
||||||
@@ -51,8 +52,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
|||||||
const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation");
|
const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation");
|
||||||
const isElectron = getIsElectron();
|
const isElectron = getIsElectron();
|
||||||
const isMac = getIsMac();
|
const isMac = getIsMac();
|
||||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type);
|
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
|
||||||
const isSearchOrBook = ["search", "book"].includes(note.type);
|
const isSearchOrBook = ["search", "book"].includes(note.type);
|
||||||
|
const [ syncServerHost ] = useTriliumOption("syncServerHost");
|
||||||
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +92,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
|||||||
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
|
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
|
||||||
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
|
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
|
||||||
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
||||||
|
{(syncServerHost && isElectron) &&
|
||||||
|
<CommandItem command="openNoteOnServer" icon="bx bx-world" disabled={!syncServerHost} text={t("note_actions.open_note_on_server")} />
|
||||||
|
}
|
||||||
<FormDropdownDivider />
|
<FormDropdownDivider />
|
||||||
|
|
||||||
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
|
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
|
||||||
|
|||||||
@@ -36,30 +36,32 @@ export default function NoteInfoTab({ note }: TabContext) {
|
|||||||
return (
|
return (
|
||||||
<div className="note-info-widget">
|
<div className="note-info-widget">
|
||||||
{note && (
|
{note && (
|
||||||
<table className="note-info-widget-table">
|
<>
|
||||||
<tbody>
|
<div className="note-info-item">
|
||||||
<tr>
|
<span>{t("note_info_widget.note_id")}:</span>
|
||||||
<th>{t("note_info_widget.note_id")}:</th>
|
<span className="note-info-id">{note.noteId}</span>
|
||||||
<td class="note-info-id">{note.noteId}</td>
|
</div>
|
||||||
<th>{t("note_info_widget.created")}:</th>
|
<div className="note-info-item">
|
||||||
<td>{formatDateTime(metadata?.dateCreated)}</td>
|
<span>{t("note_info_widget.created")}:</span>
|
||||||
<th>{t("note_info_widget.modified")}:</th>
|
<span>{formatDateTime(metadata?.dateCreated)}</span>
|
||||||
<td>{formatDateTime(metadata?.dateModified)}</td>
|
</div>
|
||||||
</tr>
|
<div className="note-info-item">
|
||||||
|
<span>{t("note_info_widget.modified")}:</span>
|
||||||
<tr>
|
<span>{formatDateTime(metadata?.dateModified)}</span>
|
||||||
<th>{t("note_info_widget.type")}:</th>
|
</div>
|
||||||
<td>
|
<div className="note-info-item">
|
||||||
<span class="note-info-type">{note.type}</span>{' '}
|
<span>{t("note_info_widget.type")}:</span>
|
||||||
{ note.mime && <span class="note-info-mime">({note.mime})</span> }
|
<span>
|
||||||
</td>
|
<span className="note-info-type">{note.type}</span>{' '}
|
||||||
|
{note.mime && <span className="note-info-mime">({note.mime})</span>}
|
||||||
<th title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</th>
|
</span>
|
||||||
<td colSpan={3}>
|
</div>
|
||||||
|
<div className="note-info-item">
|
||||||
|
<span title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</span>
|
||||||
|
<span className="note-info-size-col-span">
|
||||||
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
|
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
|
||||||
<Button
|
<Button
|
||||||
className="calculate-button"
|
className="calculate-button"
|
||||||
style={{ padding: "0px 10px 0px 10px" }}
|
|
||||||
icon="bx bx-calculator"
|
icon="bx bx-calculator"
|
||||||
text={t("note_info_widget.calculate")}
|
text={t("note_info_widget.calculate")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -76,17 +78,16 @@ export default function NoteInfoTab({ note }: TabContext) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="note-sizes-wrapper">
|
<span className="note-sizes-wrapper">
|
||||||
<span class="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
|
<span className="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
|
||||||
{" "}
|
{" "}
|
||||||
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
|
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
|
||||||
<span class="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
|
<span className="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
|
||||||
}
|
}
|
||||||
{isLoading && <LoadingSpinner />}
|
{isLoading && <LoadingSpinner />}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</>
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { TabContext } from "./ribbon-interface";
|
import { TabContext } from "./ribbon-interface";
|
||||||
import NoteMapWidget from "../note_map";
|
import { useElementSize, useWindowSize } from "../react/hooks";
|
||||||
import { useElementSize, useLegacyWidget, useWindowSize } from "../react/hooks";
|
|
||||||
import ActionButton from "../react/ActionButton";
|
import ActionButton from "../react/ActionButton";
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import NoteMap from "../note_map/NoteMap";
|
||||||
|
|
||||||
const SMALL_SIZE_HEIGHT = "300px";
|
const SMALL_SIZE_HEIGHT = "300px";
|
||||||
|
|
||||||
export default function NoteMapTab({ noteContext }: TabContext) {
|
export default function NoteMapTab({ note }: TabContext) {
|
||||||
const [ isExpanded, setExpanded ] = useState(false);
|
const [ isExpanded, setExpanded ] = useState(false);
|
||||||
const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT);
|
const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { windowHeight } = useWindowSize();
|
const { windowHeight } = useWindowSize();
|
||||||
const containerSize = useElementSize(containerRef);
|
const containerSize = useElementSize(containerRef);
|
||||||
|
|
||||||
const [ noteMapContainer, noteMapWidget ] = useLegacyWidget(() => new NoteMapWidget("ribbon"), {
|
|
||||||
noteContext,
|
|
||||||
containerClassName: "note-map-container"
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded && containerRef.current && containerSize) {
|
if (isExpanded && containerRef.current && containerSize) {
|
||||||
const height = windowHeight - containerSize.top;
|
const height = windowHeight - containerSize.top;
|
||||||
@@ -27,11 +22,10 @@ export default function NoteMapTab({ noteContext }: TabContext) {
|
|||||||
setHeight(SMALL_SIZE_HEIGHT);
|
setHeight(SMALL_SIZE_HEIGHT);
|
||||||
}
|
}
|
||||||
}, [ isExpanded, containerRef, windowHeight, containerSize?.top ]);
|
}, [ isExpanded, containerRef, windowHeight, containerSize?.top ]);
|
||||||
useEffect(() => noteMapWidget.setDimensions(), [ containerSize?.width, height ]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="note-map-ribbon-widget" style={{ height }} ref={containerRef}>
|
<div className="note-map-ribbon-widget" style={{ height }} ref={containerRef}>
|
||||||
{noteMapContainer}
|
{note && <NoteMap note={note} widgetMode="ribbon" parentRef={containerRef} />}
|
||||||
|
|
||||||
{!isExpanded ? (
|
{!isExpanded ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
|||||||
@@ -238,11 +238,6 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus on show.
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => editorRef.current?.focus(), 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Interaction with CKEditor.
|
// Interaction with CKEditor.
|
||||||
useLegacyImperativeHandlers(useMemo(() => ({
|
useLegacyImperativeHandlers(useMemo(() => ({
|
||||||
loadReferenceLinkTitle: async ($el: JQuery<HTMLElement>, href: string) => {
|
loadReferenceLinkTitle: async ($el: JQuery<HTMLElement>, href: string) => {
|
||||||
@@ -363,6 +358,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
|
|||||||
}}
|
}}
|
||||||
onKeyDown={() => attributeDetailWidget.hide()}
|
onKeyDown={() => attributeDetailWidget.hide()}
|
||||||
onBlur={() => save()}
|
onBlur={() => save()}
|
||||||
|
onInitialized={() => editorRef.current?.focus()}
|
||||||
disableNewlines disableSpellcheck
|
disableNewlines disableSpellcheck
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -160,17 +160,20 @@
|
|||||||
/* #region Note info */
|
/* #region Note info */
|
||||||
.note-info-widget {
|
.note-info-widget {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-info-widget-table {
|
.note-info-item {
|
||||||
max-width: 100%;
|
display: flex;
|
||||||
display: block;
|
align-items: baseline;
|
||||||
overflow-x: auto;
|
padding-inline-end: 15px;
|
||||||
white-space: nowrap;
|
padding-block: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-info-widget-table td, .note-info-widget-table th {
|
.note-info-item > span:first-child {
|
||||||
padding: 5px;
|
padding-inline-end: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-info-mime {
|
.note-info-mime {
|
||||||
@@ -186,6 +189,10 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-info-widget .calculate-button {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region Similar Notes */
|
/* #region Similar Notes */
|
||||||
|
|||||||
46
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
46
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useRef } from "preact/hooks";
|
||||||
|
import { useEditorSpacedUpdate, useLegacyWidget } from "../react/hooks";
|
||||||
|
import { type TypeWidgetProps } from "./type_widget";
|
||||||
|
import LlmChatPanel from "../llm_chat";
|
||||||
|
|
||||||
|
export default function AiChat({ note, noteContext }: TypeWidgetProps) {
|
||||||
|
const dataRef = useRef<object>();
|
||||||
|
const spacedUpdate = useEditorSpacedUpdate({
|
||||||
|
note,
|
||||||
|
noteContext,
|
||||||
|
getData: async () => ({
|
||||||
|
content: JSON.stringify(dataRef.current)
|
||||||
|
}),
|
||||||
|
onContentChange: (newContent) => {
|
||||||
|
try {
|
||||||
|
dataRef.current = JSON.parse(newContent);
|
||||||
|
llmChatPanel.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
dataRef.current = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [ ChatWidget, llmChatPanel ] = useLegacyWidget(() => {
|
||||||
|
const llmChatPanel = new LlmChatPanel();
|
||||||
|
llmChatPanel.setDataCallbacks(
|
||||||
|
async (data) => {
|
||||||
|
dataRef.current = data;
|
||||||
|
spacedUpdate.scheduleUpdate();
|
||||||
|
},
|
||||||
|
async () => dataRef.current
|
||||||
|
);
|
||||||
|
return llmChatPanel;
|
||||||
|
}, {
|
||||||
|
noteContext,
|
||||||
|
containerStyle: {
|
||||||
|
height: "100%"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
llmChatPanel.setNoteId(note.noteId);
|
||||||
|
llmChatPanel.setCurrentNoteId(note.noteId);
|
||||||
|
}, [ note ]);
|
||||||
|
|
||||||
|
return ChatWidget;
|
||||||
|
}
|
||||||
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 */
|
||||||
310
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
310
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { t } from "i18next";
|
||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import "./Attachment.css";
|
||||||
|
import NoteLink from "../react/NoteLink";
|
||||||
|
import Button from "../react/Button";
|
||||||
|
import { useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import HelpButton from "../react/HelpButton";
|
||||||
|
import FAttachment from "../../entities/fattachment";
|
||||||
|
import Alert from "../react/Alert";
|
||||||
|
import utils from "../../services/utils";
|
||||||
|
import content_renderer from "../../services/content_renderer";
|
||||||
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import froca from "../../services/froca";
|
||||||
|
import Dropdown from "../react/Dropdown";
|
||||||
|
import Icon from "../react/Icon";
|
||||||
|
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||||
|
import open from "../../services/open";
|
||||||
|
import toast from "../../services/toast";
|
||||||
|
import link from "../../services/link";
|
||||||
|
import image from "../../services/image";
|
||||||
|
import FormFileUpload from "../react/FormFileUpload";
|
||||||
|
import server from "../../services/server";
|
||||||
|
import dialog from "../../services/dialog";
|
||||||
|
import ws from "../../services/ws";
|
||||||
|
import appContext from "../../components/app_context";
|
||||||
|
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||||
|
import options from "../../services/options";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the full list of attachments of a note and allows the user to interact with them.
|
||||||
|
*/
|
||||||
|
export function AttachmentList({ note }: TypeWidgetProps) {
|
||||||
|
const [ attachments, setAttachments ] = useState<FAttachment[]>([]);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
note.getAttachments().then(attachments => setAttachments(Array.from(attachments)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, [ note ]);
|
||||||
|
|
||||||
|
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||||
|
if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AttachmentListHeader noteId={note.noteId} />
|
||||||
|
|
||||||
|
<div className="attachment-list-wrapper">
|
||||||
|
{attachments.length ? (
|
||||||
|
attachments.map(attachment => <AttachmentInfo key={attachment.attachmentId} attachment={attachment} />)
|
||||||
|
) : (
|
||||||
|
<Alert type="info">
|
||||||
|
{t("attachment_list.no_attachments")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentListHeader({ noteId }: { noteId: string }) {
|
||||||
|
const parentComponent = useContext(ParentComponent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="links-wrapper">
|
||||||
|
<div>
|
||||||
|
{t("attachment_list.owning_note")}{" "}<NoteLink notePath={noteId} />
|
||||||
|
</div>
|
||||||
|
<div className="attachment-actions-toolbar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon="bx bx-folder-open"
|
||||||
|
text={t("attachment_list.upload_attachments")}
|
||||||
|
onClick={() => parentComponent?.triggerCommand("showUploadAttachmentsDialog", { noteId })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HelpButton
|
||||||
|
helpPage="0vhv7lsOLy82"
|
||||||
|
title={t("attachment_list.open_help_page")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays information about a single attachment.
|
||||||
|
*/
|
||||||
|
export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
|
||||||
|
const [ attachment, setAttachment ] = useState<FAttachment | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewScope?.attachmentId) return;
|
||||||
|
froca.getAttachment(viewScope.attachmentId).then(setAttachment);
|
||||||
|
}, [ viewScope ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="links-wrapper use-tn-links">
|
||||||
|
{t("attachment_detail.owning_note")}{" "}
|
||||||
|
<NoteLink notePath={note.noteId} />
|
||||||
|
{t("attachment_detail.you_can_also_open")}{" "}
|
||||||
|
<NoteLink
|
||||||
|
notePath={note.noteId}
|
||||||
|
viewScope={{ viewMode: "attachments" }}
|
||||||
|
title={t("attachment_detail.list_of_all_attachments")}
|
||||||
|
/>
|
||||||
|
<HelpButton
|
||||||
|
helpPage="0vhv7lsOLy82"
|
||||||
|
title={t("attachment_list.open_help_page")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attachment-wrapper">
|
||||||
|
{attachment !== null ? (
|
||||||
|
attachment && <AttachmentInfo attachment={attachment} isFullDetail />
|
||||||
|
) : (
|
||||||
|
<strong>{t("attachment_detail.attachment_deleted")}</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
|
||||||
|
const contentWrapper = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
|
||||||
|
.then(({ $renderedContent }) => {
|
||||||
|
contentWrapper.current?.replaceChildren(...$renderedContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, [ attachment ]);
|
||||||
|
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||||
|
if (loadResults.getAttachmentRows().find(attachment => attachment.attachmentId)) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyAttachmentLinkToClipboard() {
|
||||||
|
if (attachment.role === "image") {
|
||||||
|
const $contentWrapper = refToJQuerySelector(contentWrapper);
|
||||||
|
image.copyImageReferenceToClipboard($contentWrapper);
|
||||||
|
} else if (attachment.role === "file") {
|
||||||
|
const $link = await link.createLink(attachment.ownerId, {
|
||||||
|
referenceLink: true,
|
||||||
|
viewScope: {
|
||||||
|
viewMode: "attachments",
|
||||||
|
attachmentId: attachment.attachmentId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.copyHtmlToClipboard($link[0].outerHTML);
|
||||||
|
|
||||||
|
toast.showMessage(t("attachment_detail_2.link_copied"));
|
||||||
|
} else {
|
||||||
|
throw new Error(t("attachment_detail_2.unrecognized_role", { role: attachment.role }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="attachment-detail-widget">
|
||||||
|
<div className={`attachment-detail-wrapper ${isFullDetail ? "full-detail" : "list-view"} ${attachment.utcDateScheduledForErasureSince ? "scheduled-for-deletion" : ""}`}>
|
||||||
|
<div className="attachment-title-line">
|
||||||
|
<AttachmentActions attachment={attachment} copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard} />
|
||||||
|
<h4 className="attachment-title">
|
||||||
|
{!isFullDetail ? (
|
||||||
|
<NoteLink
|
||||||
|
notePath={attachment.ownerId}
|
||||||
|
title={attachment.title}
|
||||||
|
viewScope={{
|
||||||
|
viewMode: "attachments",
|
||||||
|
attachmentId: attachment.attachmentId
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (attachment.title)}
|
||||||
|
</h4>
|
||||||
|
<div className="attachment-details">
|
||||||
|
{t("attachment_detail_2.role_and_size", { role: attachment.role, size: utils.formatSize(attachment.contentLength) })}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1 1;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
|
||||||
|
<div ref={contentWrapper} className="attachment-content-wrapper" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledForErasureSince: string }) {
|
||||||
|
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
|
||||||
|
// use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
|
||||||
|
const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
|
||||||
|
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
|
||||||
|
const willBeDeletedInMs = deletionTimestamp - Date.now();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className="attachment-deletion-warning" type="info">
|
||||||
|
{ willBeDeletedInMs >= 60000
|
||||||
|
? t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) })
|
||||||
|
: t("attachment_detail_2.will_be_deleted_soon")}
|
||||||
|
{t("attachment_detail_2.deletion_reason")}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) {
|
||||||
|
const isElectron = utils.isElectron();
|
||||||
|
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="attachment-actions-container">
|
||||||
|
<Dropdown
|
||||||
|
className="attachment-actions"
|
||||||
|
text={<Icon icon="bx bx-dots-vertical-rounded" />}
|
||||||
|
buttonClassName="icon-action-always-border"
|
||||||
|
iconAction
|
||||||
|
>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-file-find"
|
||||||
|
title={t("attachments_actions.open_externally_title")}
|
||||||
|
onClick={() => open.openAttachmentExternally(attachment.attachmentId, attachment.mime)}
|
||||||
|
>{t("attachments_actions.open_externally")}</FormListItem>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-customize"
|
||||||
|
title={t("attachments_actions.open_custom_title")}
|
||||||
|
onClick={() => open.openAttachmentCustom(attachment.attachmentId, attachment.mime)}
|
||||||
|
disabled={!isElectron}
|
||||||
|
disabledTooltip={!isElectron ? t("attachments_actions.open_custom_client_only") : t("attachments_actions.open_externally_detail_page")}
|
||||||
|
>{t("attachments_actions.open_custom")}</FormListItem>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-download"
|
||||||
|
onClick={() => open.downloadAttachment(attachment.attachmentId)}
|
||||||
|
>{t("attachments_actions.download")}</FormListItem>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-link"
|
||||||
|
onClick={copyAttachmentLinkToClipboard}
|
||||||
|
>{t("attachments_actions.copy_link_to_clipboard")}</FormListItem>
|
||||||
|
<FormDropdownDivider />
|
||||||
|
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-upload"
|
||||||
|
onClick={() => fileUploadRef.current?.click()}
|
||||||
|
>{t("attachments_actions.upload_new_revision")}</FormListItem>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-rename"
|
||||||
|
onClick={async () => {
|
||||||
|
const attachmentTitle = await dialog.prompt({
|
||||||
|
title: t("attachments_actions.rename_attachment"),
|
||||||
|
message: t("attachments_actions.enter_new_name"),
|
||||||
|
defaultValue: attachment.title
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachmentTitle?.trim()) return;
|
||||||
|
await server.put(`attachments/${attachment.attachmentId}/rename`, { title: attachmentTitle });
|
||||||
|
}}
|
||||||
|
>{t("attachments_actions.rename_attachment")}</FormListItem>
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-trash destructive-action-icon"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await dialog.confirm(t("attachments_actions.delete_confirm", { title: attachment.title })))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.remove(`attachments/${attachment.attachmentId}`);
|
||||||
|
toast.showMessage(t("attachments_actions.delete_success", { title: attachment.title }));
|
||||||
|
}}
|
||||||
|
>{t("attachments_actions.delete_attachment")}</FormListItem>
|
||||||
|
<FormDropdownDivider />
|
||||||
|
|
||||||
|
<FormListItem
|
||||||
|
icon="bx bx-note"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!(await dialog.confirm(t("attachments_actions.convert_confirm", { title: attachment.title })))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { note: newNote } = await server.post<ConvertAttachmentToNoteResponse>(`attachments/${attachment.attachmentId}/convert-to-note`);
|
||||||
|
toast.showMessage(t("attachments_actions.convert_success", { title: attachment.title }));
|
||||||
|
await ws.waitForMaxKnownEntityChangeId();
|
||||||
|
await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
|
||||||
|
}}
|
||||||
|
>{t("attachments_actions.convert_attachment_into_note")}</FormListItem>
|
||||||
|
|
||||||
|
<FormFileUpload
|
||||||
|
inputRef={fileUploadRef}
|
||||||
|
hidden
|
||||||
|
onChange={async files => {
|
||||||
|
const fileToUpload = files?.item(0);
|
||||||
|
if (fileToUpload) {
|
||||||
|
const result = await server.upload(`attachments/${attachment.attachmentId}/file`, fileToUpload);
|
||||||
|
if (result.uploaded) {
|
||||||
|
toast.showMessage(t("attachments_actions.upload_success"));
|
||||||
|
} else {
|
||||||
|
toast.showError(t("attachments_actions.upload_failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
35
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
35
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { RawHtmlBlock } from "../react/RawHtml";
|
||||||
|
import renderDoc from "../../services/doc_renderer";
|
||||||
|
import "./Doc.css";
|
||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
|
||||||
|
export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) {
|
||||||
|
const [ html, setHtml ] = useState<string>();
|
||||||
|
const initialized = useRef<Promise<void> | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!note) return;
|
||||||
|
|
||||||
|
initialized.current = renderDoc(note).then($content => {
|
||||||
|
setHtml($content.html());
|
||||||
|
});
|
||||||
|
}, [ note ]);
|
||||||
|
|
||||||
|
useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => {
|
||||||
|
if (eventNtxId !== ntxId) return;
|
||||||
|
await initialized.current;
|
||||||
|
resolve(refToJQuerySelector(containerRef));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RawHtmlBlock
|
||||||
|
containerRef={containerRef}
|
||||||
|
className={`note-detail-doc-content ck-content ${viewScope?.viewMode === "contextual-help" ? "contextual-help" : ""}`}
|
||||||
|
html={html}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/client/src/widgets/type_widgets/File.css
Normal file
41
apps/client/src/widgets/type_widgets/File.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.type-file .note-detail {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-file {
|
||||||
|
padding: 10px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-split.full-content-width .note-detail-file {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail.full-height .note-detail-file[data-preview-type="pdf"],
|
||||||
|
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content {
|
||||||
|
background-color: var(--accented-background-color);
|
||||||
|
padding: 15px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-file > .pdf-preview,
|
||||||
|
.note-detail-file > .video-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex-grow: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-file > .audio-preview {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
78
apps/client/src/widgets/type_widgets/File.tsx
Normal file
78
apps/client/src/widgets/type_widgets/File.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useNoteBlob } from "../react/hooks";
|
||||||
|
import "./File.css";
|
||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import FNote from "../../entities/fnote";
|
||||||
|
import { getUrlForDownload } from "../../services/open";
|
||||||
|
import Alert from "../react/Alert";
|
||||||
|
import { t } from "../../services/i18n";
|
||||||
|
|
||||||
|
const TEXT_MAX_NUM_CHARS = 5000;
|
||||||
|
|
||||||
|
export default function File({ note }: TypeWidgetProps) {
|
||||||
|
const blob = useNoteBlob(note);
|
||||||
|
|
||||||
|
if (blob?.content) {
|
||||||
|
return <TextPreview content={blob.content} />
|
||||||
|
} else if (note.mime === "application/pdf") {
|
||||||
|
return <PdfPreview note={note} />
|
||||||
|
} else if (note.mime.startsWith("video/")) {
|
||||||
|
return <VideoPreview note={note} />
|
||||||
|
} else if (note.mime.startsWith("audio/")) {
|
||||||
|
return <AudioPreview note={note} />
|
||||||
|
} else {
|
||||||
|
return <NoPreview />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextPreview({ content }: { content: string }) {
|
||||||
|
const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS);
|
||||||
|
const isTooLarge = trimmedContent.length !== content.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isTooLarge && (
|
||||||
|
<Alert type="info">
|
||||||
|
{t("file.too_big", { maxNumChars: TEXT_MAX_NUM_CHARS })}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<pre class="file-preview-content">{trimmedContent}</pre>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfPreview({ note }: { note: FNote }) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
class="pdf-preview"
|
||||||
|
src={getUrlForDownload(`api/notes/${note.noteId}/open`)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPreview({ note }: { note: FNote }) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
class="video-preview"
|
||||||
|
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||||
|
datatype={note?.mime}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioPreview({ note }: { note: FNote }) {
|
||||||
|
return (
|
||||||
|
<audio
|
||||||
|
class="audio-preview"
|
||||||
|
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoPreview() {
|
||||||
|
return (
|
||||||
|
<Alert className="file-preview-not-available" type="info">
|
||||||
|
{t("file.file_preview_not_available")}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/client/src/widgets/type_widgets/Image.css
Normal file
24
apps/client/src/widgets/type_widgets/Image.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.type-image .note-detail {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-image {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-image-view {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
52
apps/client/src/widgets/type_widgets/Image.tsx
Normal file
52
apps/client/src/widgets/type_widgets/Image.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { createImageSrcUrl } from "../../services/utils";
|
||||||
|
import { useTriliumEvent, useUniqueName } from "../react/hooks";
|
||||||
|
import "./Image.css";
|
||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||||
|
import image_context_menu from "../../menus/image_context_menu";
|
||||||
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import { copyImageReferenceToClipboard } from "../../services/image";
|
||||||
|
|
||||||
|
export default function Image({ note, ntxId }: TypeWidgetProps) {
|
||||||
|
const uniqueId = useUniqueName("image");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||||
|
|
||||||
|
// Set up pan & zoom
|
||||||
|
useEffect(() => {
|
||||||
|
const zoomInstance = WheelZoom.create(`#${uniqueId}`, {
|
||||||
|
maxScale: 50,
|
||||||
|
speed: 1.3,
|
||||||
|
zoomOnClick: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => zoomInstance.destroy();
|
||||||
|
}, [ note ]);
|
||||||
|
|
||||||
|
// Set up context menu
|
||||||
|
useEffect(() => image_context_menu.setupContextMenu(refToJQuerySelector(containerRef)), []);
|
||||||
|
|
||||||
|
// Copy reference events
|
||||||
|
useTriliumEvent("copyImageReferenceToClipboard", ({ ntxId: eventNtxId }) => {
|
||||||
|
if (eventNtxId !== ntxId) return;
|
||||||
|
copyImageReferenceToClipboard(refToJQuerySelector(containerRef));
|
||||||
|
});
|
||||||
|
|
||||||
|
// React to new revisions.
|
||||||
|
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||||
|
if (loadResults.isNoteReloaded(note.noteId)) {
|
||||||
|
setRefreshCounter(refreshCounter + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="note-detail-image-wrapper">
|
||||||
|
<img
|
||||||
|
id={uniqueId}
|
||||||
|
className="note-detail-image-view"
|
||||||
|
src={createImageSrcUrl(note)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { EditorConfig } from "@triliumnext/codemirror";
|
import { useCallback } from "preact/hooks";
|
||||||
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js";
|
import SvgSplitEditor from "./helpers/SvgSplitEditor";
|
||||||
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js";
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
|
||||||
|
|
||||||
let idCounter = 1;
|
let idCounter = 1;
|
||||||
let registeredErrorReporter = false;
|
let registeredErrorReporter = false;
|
||||||
|
|
||||||
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
|
export default function Mermaid(props: TypeWidgetProps) {
|
||||||
|
const renderSvg = useCallback(async (content: string) => {
|
||||||
static getType() {
|
|
||||||
return "mermaid";
|
|
||||||
}
|
|
||||||
|
|
||||||
get attachmentName(): string {
|
|
||||||
return "mermaid-export";
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderSvg(content: string) {
|
|
||||||
const mermaid = (await import("mermaid")).default;
|
const mermaid = (await import("mermaid")).default;
|
||||||
await loadElkIfNeeded(mermaid, content);
|
await loadElkIfNeeded(mermaid, content);
|
||||||
if (!registeredErrorReporter) {
|
if (!registeredErrorReporter) {
|
||||||
@@ -31,6 +23,13 @@ export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
|
|||||||
idCounter++;
|
idCounter++;
|
||||||
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
|
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
|
||||||
return postprocessMermaidSvg(svg);
|
return postprocessMermaidSvg(svg);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SvgSplitEditor
|
||||||
|
attachmentName="mermaid-export"
|
||||||
|
renderSvg={renderSvg}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
124
apps/client/src/widgets/type_widgets/MindMap.css
Normal file
124
apps/client/src/widgets/type_widgets/MindMap.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
.note-detail-mind-map {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-mind-map .mind-map-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
inset-inline-end: 20px;
|
||||||
|
bottom: 80px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--panel-bgcolor);
|
||||||
|
color: var(--main-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 1px 2px #0003;
|
||||||
|
width: 240px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 15px 15px;
|
||||||
|
transition: .3s all
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu.close {
|
||||||
|
height: 29px;
|
||||||
|
width: 46px;
|
||||||
|
overflow: hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .button-container {
|
||||||
|
padding: 3px 0;
|
||||||
|
direction: rtl
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu #nm-tag {
|
||||||
|
margin-top: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .nm-fontsize-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .nm-fontsize-container div {
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 2px #0003;
|
||||||
|
background-color: #fff;
|
||||||
|
color: tomato;
|
||||||
|
border-radius: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .nm-fontcolor-container {
|
||||||
|
margin-bottom: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu input,
|
||||||
|
.map-container .node-menu textarea {
|
||||||
|
background: var(--input-background-color);
|
||||||
|
border: 1px solid var(--panel-border-color);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
color: var(--main-color);
|
||||||
|
padding: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu textarea {
|
||||||
|
resize: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .split6 {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16.66%;
|
||||||
|
margin-bottom: 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .palette {
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
border: 1px solid #edf1f2;
|
||||||
|
margin: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .nmenu-selected,
|
||||||
|
.map-container .node-menu .palette:hover {
|
||||||
|
box-shadow: tomato 0 0 0 2px;
|
||||||
|
background-color: #c7e9fa
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .size-selected {
|
||||||
|
background-color: tomato !important;
|
||||||
|
border-color: tomato;
|
||||||
|
fill: #fff;
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .size-selected svg {
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .bof {
|
||||||
|
text-align: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .bof span {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container .node-menu .bof .selected {
|
||||||
|
background-color: tomato;
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
171
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
171
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import { MindElixirData, MindElixirInstance, Operation, default as VanillaMindElixir } from "mind-elixir";
|
||||||
|
import { HTMLAttributes, RefObject } from "preact";
|
||||||
|
// allow node-menu plugin css to be bundled by webpack
|
||||||
|
import nodeMenu from "@mind-elixir/node-menu";
|
||||||
|
import "mind-elixir/style";
|
||||||
|
import "@mind-elixir/node-menu/dist/style.css";
|
||||||
|
import "./MindMap.css";
|
||||||
|
import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents } from "../react/hooks";
|
||||||
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import utils from "../../services/utils";
|
||||||
|
|
||||||
|
const NEW_TOPIC_NAME = "";
|
||||||
|
|
||||||
|
interface MindElixirProps {
|
||||||
|
apiRef?: RefObject<MindElixirInstance>;
|
||||||
|
containerProps?: Omit<HTMLAttributes<HTMLDivElement>, "ref">;
|
||||||
|
containerRef?: RefObject<HTMLDivElement>;
|
||||||
|
editable: boolean;
|
||||||
|
onChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||||
|
const apiRef = useRef<MindElixirInstance>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||||
|
const spacedUpdate = useEditorSpacedUpdate({
|
||||||
|
note,
|
||||||
|
noteContext,
|
||||||
|
getData: async () => {
|
||||||
|
if (!apiRef.current) return;
|
||||||
|
return {
|
||||||
|
content: apiRef.current.getDataString(),
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
role: "image",
|
||||||
|
title: "mindmap-export.svg",
|
||||||
|
mime: "image/svg+xml",
|
||||||
|
content: await apiRef.current.exportSvg().text(),
|
||||||
|
position: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContentChange: (content) => {
|
||||||
|
let newContent: MindElixirData;
|
||||||
|
if (content) {
|
||||||
|
try {
|
||||||
|
newContent = JSON.parse(content) as MindElixirData;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
console.debug("Wrong JSON content: ", content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newContent = VanillaMindElixir.new(NEW_TOPIC_NAME)
|
||||||
|
}
|
||||||
|
apiRef.current?.init(newContent!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow search.
|
||||||
|
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
|
||||||
|
if (eventNtxId !== ntxId) return;
|
||||||
|
resolve(refToJQuerySelector(containerRef).find(".map-canvas"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export as PNG or SVG.
|
||||||
|
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
|
||||||
|
if (eventNtxId !== ntxId || !apiRef.current) return;
|
||||||
|
const title = note.title;
|
||||||
|
const svg = await apiRef.current.exportSvg().text();
|
||||||
|
if (eventName === "exportSvg") {
|
||||||
|
utils.downloadSvg(title, svg);
|
||||||
|
} else {
|
||||||
|
utils.downloadSvgAsPng(title, svg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
/*
|
||||||
|
* Some global shortcuts interfere with the default shortcuts of the mind map,
|
||||||
|
* as defined here: https://mind-elixir.com/docs/guides/shortcuts
|
||||||
|
*/
|
||||||
|
if (e.key === "F1") {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom controls
|
||||||
|
const isCtrl = e.ctrlKey && !e.altKey && !e.metaKey;
|
||||||
|
if (isCtrl && (e.key == "-" || e.key == "=" || e.key == "0")) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MindElixir
|
||||||
|
containerRef={containerRef}
|
||||||
|
apiRef={apiRef}
|
||||||
|
onChange={() => spacedUpdate.scheduleUpdate()}
|
||||||
|
editable={!isReadOnly}
|
||||||
|
containerProps={{
|
||||||
|
className: "mind-map-container",
|
||||||
|
onKeyDown
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) {
|
||||||
|
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
|
||||||
|
const apiRef = useRef<MindElixirInstance>(null);
|
||||||
|
|
||||||
|
function reinitialize() {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const mind = new VanillaMindElixir({
|
||||||
|
el: containerRef.current,
|
||||||
|
editable
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
mind.install(nodeMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiRef.current = mind;
|
||||||
|
if (externalApiRef) {
|
||||||
|
externalApiRef.current = mind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reinitialize();
|
||||||
|
return () => {
|
||||||
|
apiRef.current?.destroy();
|
||||||
|
apiRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = apiRef.current?.getData();
|
||||||
|
reinitialize();
|
||||||
|
if (data) {
|
||||||
|
apiRef.current?.init(data);
|
||||||
|
}
|
||||||
|
}, [ editable ]);
|
||||||
|
|
||||||
|
// On change listener.
|
||||||
|
useEffect(() => {
|
||||||
|
const bus = apiRef.current?.bus;
|
||||||
|
if (!onChange || !bus) return;
|
||||||
|
|
||||||
|
const operationListener = (operation: Operation) => {
|
||||||
|
if (operation.name !== "beginEdit") {
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.addListener("operation", operationListener);
|
||||||
|
bus.addListener("changeDirection", onChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bus.removeListener("operation", operationListener);
|
||||||
|
bus.removeListener("changeDirection", onChange);
|
||||||
|
};
|
||||||
|
}, [ onChange ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} {...containerProps} />
|
||||||
|
)
|
||||||
|
}
|
||||||
13
apps/client/src/widgets/type_widgets/NoteMap.tsx
Normal file
13
apps/client/src/widgets/type_widgets/NoteMap.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
|
import NoteMapEl from "../note_map/NoteMap";
|
||||||
|
import { useRef } from "preact/hooks";
|
||||||
|
|
||||||
|
export default function NoteMap({ note }: TypeWidgetProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<NoteMapEl parentRef={containerRef} note={note} widgetMode="type" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.protected-session-password-component {
|
||||||
|
width: 300px;
|
||||||
|
margin: 30px auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protected-session-password-component input,
|
||||||
|
.protected-session-password-component button {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
40
apps/client/src/widgets/type_widgets/ProtectedSession.tsx
Normal file
40
apps/client/src/widgets/type_widgets/ProtectedSession.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useCallback, useRef } from "preact/hooks";
|
||||||
|
import { t } from "../../services/i18n";
|
||||||
|
import Button from "../react/Button";
|
||||||
|
import FormGroup from "../react/FormGroup";
|
||||||
|
import FormTextBox from "../react/FormTextBox";
|
||||||
|
import "./ProtectedSession.css";
|
||||||
|
import protected_session from "../../services/protected_session";
|
||||||
|
import type { TargetedSubmitEvent } from "preact";
|
||||||
|
|
||||||
|
export default function ProtectedSession() {
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const submitCallback = useCallback((e: TargetedSubmitEvent<HTMLFormElement>) => {
|
||||||
|
if (!passwordRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const password = String(passwordRef.current.value);
|
||||||
|
passwordRef.current.value = "";
|
||||||
|
protected_session.setupProtectedSession(password);
|
||||||
|
}, [ passwordRef ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form class="protected-session-password-form" onSubmit={submitCallback}>
|
||||||
|
<FormGroup name="protected-session-password-in-detail" label={t("protected_session.enter_password_instruction")}>
|
||||||
|
<FormTextBox
|
||||||
|
type="password"
|
||||||
|
className="protected-session-password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
inputRef={passwordRef}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("protected_session.start_session_button")}
|
||||||
|
primary
|
||||||
|
keyboardShortcut="Enter"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user