mirror of
https://github.com/zadam/trilium.git
synced 2026-02-05 05:59:19 +01:00
Compare commits
300 Commits
standalone
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec915177ad | ||
|
|
5adee3e217 | ||
|
|
278d82645e | ||
|
|
e66f13b471 | ||
|
|
0e2955b57e | ||
|
|
2a4d5ec1ec | ||
|
|
07ce63de69 | ||
|
|
b539862eef | ||
|
|
ac4be3f8a8 | ||
|
|
0dc2d07b58 | ||
|
|
b097f9dc21 | ||
|
|
8aa4a97480 | ||
|
|
3e54d0ceae | ||
|
|
e1cec3404a | ||
|
|
9a2b7fbda1 | ||
|
|
e0b4ebed93 | ||
|
|
e00e3999c5 | ||
|
|
2cbe96d815 | ||
|
|
ac3b289c9e | ||
|
|
62534e0e93 | ||
|
|
b802c3174c | ||
|
|
aee1a6e1f0 | ||
|
|
46f1cd38e0 | ||
|
|
7f891ef523 | ||
|
|
06af5e15cd | ||
|
|
af89a0a883 | ||
|
|
fa72eb2edb | ||
|
|
c1ea94423b | ||
|
|
1e70d066bd | ||
|
|
ab89f16e7c | ||
|
|
8c848a4cb5 | ||
|
|
e021a54d2d | ||
|
|
70523574b0 | ||
|
|
66e0f1ab19 | ||
|
|
671a05470e | ||
|
|
99eec0c41e | ||
|
|
48d06dcb06 | ||
|
|
e38df0c731 | ||
|
|
0f3f49915e | ||
|
|
416144265b | ||
|
|
fe02871e91 | ||
|
|
bb05aeeaf7 | ||
|
|
846358ccb0 | ||
|
|
dabc779727 | ||
|
|
08fd2ec64b | ||
|
|
c42c06d048 | ||
|
|
e951d60800 | ||
|
|
0d8453f6a7 | ||
|
|
52b41b1bb0 | ||
|
|
c7265017b3 | ||
|
|
634e0b6d30 | ||
|
|
1c260f5890 | ||
|
|
177aedeaae | ||
|
|
3f6f3d2565 | ||
|
|
38489dbfeb | ||
|
|
ff2a3d6a28 | ||
|
|
2c74697fb1 | ||
|
|
110e9200b9 | ||
|
|
6e792f9735 | ||
|
|
51d8b13a81 | ||
|
|
a05691fd07 | ||
|
|
d25e7915d9 | ||
|
|
1a025dfef3 | ||
|
|
84cc4194aa | ||
|
|
331a56277c | ||
|
|
bc2915adb9 | ||
|
|
703fe9a71b | ||
|
|
6c50664046 | ||
|
|
673cbc97e1 | ||
|
|
3e83766099 | ||
|
|
b453589077 | ||
|
|
654fa18ab1 | ||
|
|
7b0d91534c | ||
|
|
8d4801bb6f | ||
|
|
9e36f4f625 | ||
|
|
d83a824812 | ||
|
|
ca128f2fa9 | ||
|
|
c02642d0f9 | ||
|
|
7340709111 | ||
|
|
56d0383372 | ||
|
|
49d33ea19a | ||
|
|
979fa0359a | ||
|
|
c7381d058a | ||
|
|
5db298f031 | ||
|
|
171d948a00 | ||
|
|
facd56cdf4 | ||
|
|
ba17ec4be4 | ||
|
|
6c163b5479 | ||
|
|
79649805b8 | ||
|
|
220ca8a570 | ||
|
|
348c00f86d | ||
|
|
12b641b522 | ||
|
|
6855bc1de6 | ||
|
|
a9c5b99ae8 | ||
|
|
76f36e2fd3 | ||
|
|
afe710321c | ||
|
|
0d444daaca | ||
|
|
c8a0c9fd23 | ||
|
|
79f07ae923 | ||
|
|
c77e7a568b | ||
|
|
ff9ec2057b | ||
|
|
6a313b99e4 | ||
|
|
8a92370042 | ||
|
|
411a59ec54 | ||
|
|
2d4022044d | ||
|
|
bbc5ebd76b | ||
|
|
e9c90fcde8 | ||
|
|
911f78867f | ||
|
|
5507cc5abc | ||
|
|
0e5aa401ef | ||
|
|
d48473ab87 | ||
|
|
734efaf40c | ||
|
|
f89718d88a | ||
|
|
aa4942a0da | ||
|
|
90bb162a88 | ||
|
|
0382a4b30e | ||
|
|
490d940cd1 | ||
|
|
b090eb9359 | ||
|
|
cb9e67ce84 | ||
|
|
c4d131dd23 | ||
|
|
2667f266bf | ||
|
|
8258936d6c | ||
|
|
e4e7449078 | ||
|
|
11b020e859 | ||
|
|
72d6b83ec5 | ||
|
|
fbe5152cb3 | ||
|
|
6bef01f755 | ||
|
|
f0d4b4a6d9 | ||
|
|
a54fe62643 | ||
|
|
eb4bbd49fb | ||
|
|
ab4f1bd4f4 | ||
|
|
f8b414c354 | ||
|
|
82a21624c3 | ||
|
|
1b212ac720 | ||
|
|
c36ce3ea14 | ||
|
|
841fab77a8 | ||
|
|
fd6f910824 | ||
|
|
ce9ca1917d | ||
|
|
c702fb273c | ||
|
|
4c72d8691b | ||
|
|
35ac5fc514 | ||
|
|
92991cc03c | ||
|
|
76492475e3 | ||
|
|
e2363d860c | ||
|
|
c06a90913a | ||
|
|
e88c0f7326 | ||
|
|
2a4280b5bf | ||
|
|
d3c733c57f | ||
|
|
f91add3cd4 | ||
|
|
90e3f7508a | ||
|
|
5e981da4df | ||
|
|
2ddd5d75fc | ||
|
|
88f509cbb6 | ||
|
|
ca161bc881 | ||
|
|
676ca44cdf | ||
|
|
5d0c91202f | ||
|
|
a166f049d5 | ||
|
|
0dc4692dfc | ||
|
|
996607d096 | ||
|
|
bd907ea008 | ||
|
|
b1573b1f3b | ||
|
|
246849ce94 | ||
|
|
2c2c68261a | ||
|
|
e38a0361bf | ||
|
|
cbf879bd32 | ||
|
|
808625e564 | ||
|
|
c3b4c2f7d4 | ||
|
|
14f521fdd7 | ||
|
|
087831df5a | ||
|
|
6b0542a5bf | ||
|
|
ac57856f00 | ||
|
|
2ab1587df0 | ||
|
|
632aa6e003 | ||
|
|
9142f2df4b | ||
|
|
eea4cbbd6c | ||
|
|
bb31c20282 | ||
|
|
f8ab206744 | ||
|
|
4129b3a96e | ||
|
|
0de704bd3e | ||
|
|
5f20ce87a7 | ||
|
|
ff80154fda | ||
|
|
0d99cf9fb9 | ||
|
|
a5306b2067 | ||
|
|
e9b826e498 | ||
|
|
e7f356b87c | ||
|
|
d2abde714f | ||
|
|
20eaa79079 | ||
|
|
3cb74bb844 | ||
|
|
a72d4f425a | ||
|
|
8f3545624e | ||
|
|
8f6cfe8a04 | ||
|
|
bec7943e05 | ||
|
|
a486f5951e | ||
|
|
e8158aadec | ||
|
|
b6f107b85b | ||
|
|
5abd27f252 | ||
|
|
39648b6df8 | ||
|
|
0a7b2e3304 | ||
|
|
48db6e1756 | ||
|
|
2a38af5db6 | ||
|
|
740b1093d7 | ||
|
|
6b70412f6e | ||
|
|
43f147ec60 | ||
|
|
bf0fc57493 | ||
|
|
325b8b886c | ||
|
|
a02bbdc550 | ||
|
|
4285fd7708 | ||
|
|
96fa6eac44 | ||
|
|
05fa1ef2fb | ||
|
|
13aebc060e | ||
|
|
1aae4098d6 | ||
|
|
3367bb2e5b | ||
|
|
49fc5e1559 | ||
|
|
3a9b448a83 | ||
|
|
a45fb975c0 | ||
|
|
e070fc2f52 | ||
|
|
5f8aac31e1 | ||
|
|
07f2e2eafc | ||
|
|
2b41fb7108 | ||
|
|
81c18f1869 | ||
|
|
61fd2fe87d | ||
|
|
054cb974f1 | ||
|
|
50a19ecd74 | ||
|
|
1232909a3b | ||
|
|
9ba0e076a6 | ||
|
|
ede91c645d | ||
|
|
b6a91723e7 | ||
|
|
fddd73fdb1 | ||
|
|
ffbe8f9dc4 | ||
|
|
f80763ffb4 | ||
|
|
f65aa1b875 | ||
|
|
0c4de9a5e0 | ||
|
|
1822970e23 | ||
|
|
80615d0382 | ||
|
|
c1002ed52a | ||
|
|
83e585ed35 | ||
|
|
0e164b9daa | ||
|
|
1c9f8a2540 | ||
|
|
88ba4451eb | ||
|
|
305f195539 | ||
|
|
fbc6b853ed | ||
|
|
f3bcab813a | ||
|
|
2fa9b2e4dd | ||
|
|
fc756ba46e | ||
|
|
85a82124b1 | ||
|
|
b5cfbc92af | ||
|
|
e4f260e242 | ||
|
|
7c6c0c1fbd | ||
|
|
2f5f4dbebd | ||
|
|
94adc6af95 | ||
|
|
e3ea703f3d | ||
|
|
a14753e5f3 | ||
|
|
2ffd55c2d8 | ||
|
|
ad0ee8da32 | ||
|
|
e5cfe37b17 | ||
|
|
9247dd8b17 | ||
|
|
2f69b1d6e1 | ||
|
|
9c43930e7b | ||
|
|
f18ecfa524 | ||
|
|
b6f7453ed9 | ||
|
|
4a0b77dabb | ||
|
|
de7687b3ab | ||
|
|
85f2ec9d92 | ||
|
|
ed6b8dfc9b | ||
|
|
1539a6eabc | ||
|
|
6424d96703 | ||
|
|
15f6ac8e89 | ||
|
|
f9803a4694 | ||
|
|
13de9975e3 | ||
|
|
a64c4ef66f | ||
|
|
36e85eb3a7 | ||
|
|
20ffe7e082 | ||
|
|
ea1dc58b9a | ||
|
|
cd292ad605 | ||
|
|
03d9a6c0e5 | ||
|
|
fa54a2e67c | ||
|
|
7f2530470d | ||
|
|
a284934136 | ||
|
|
2ca6606508 | ||
|
|
bb1c691b34 | ||
|
|
19d3e1b11c | ||
|
|
e35e64caaa | ||
|
|
a3cf72c76c | ||
|
|
710e95bdee | ||
|
|
d281fb7065 | ||
|
|
e669d5041b | ||
|
|
d8d91451c8 | ||
|
|
5d5947f676 | ||
|
|
8fc889ae08 | ||
|
|
ff46493775 | ||
|
|
a1cb3b8371 | ||
|
|
51131433d3 | ||
|
|
3c8a066f76 | ||
|
|
6856a98d50 | ||
|
|
120b767a68 | ||
|
|
8c0d4cde86 | ||
|
|
280697f2f7 | ||
|
|
0650be664d | ||
|
|
60c61f553a | ||
|
|
022c967781 |
67
.github/workflows/deploy-app.yml
vendored
67
.github/workflows/deploy-app.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- standalone
|
||||
# Only run when app files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/dev.yml
vendored
4
.github/workflows/dev.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -42,5 +42,8 @@
|
||||
},
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "*", "severity": "warn" }
|
||||
],
|
||||
"cSpell.words": [
|
||||
"Trilium"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.9",
|
||||
"@redocly/cli": "2.15.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1 +0,0 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "@triliumnext/client-standalone",
|
||||
"version": "0.101.3",
|
||||
"description": "Standalone client for TriliumNext with SQLite WASM backend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"dev": "vite dev",
|
||||
"test": "vitest",
|
||||
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"js-sha1": "0.7.0",
|
||||
"js-sha512": "0.9.0",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.2",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
@@ -1,35 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* Browser-compatible router that mimics Express routing patterns.
|
||||
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
|
||||
*/
|
||||
|
||||
import { getContext, routes } from "@triliumnext/core";
|
||||
|
||||
export interface BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Convert an Express-style path pattern to a RegExp.
|
||||
* Supports :param syntax for path parameters.
|
||||
*
|
||||
* Examples:
|
||||
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
|
||||
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
|
||||
*/
|
||||
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
|
||||
// Escape special regex characters except for :param patterns
|
||||
const regexPattern = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
return {
|
||||
pattern: new RegExp(`^${regexPattern}$`),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string into an object.
|
||||
*/
|
||||
function parseQuery(search: string): Record<string, string | undefined> {
|
||||
const query: Record<string, string | undefined> = {};
|
||||
if (!search || search === '?') return query;
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, value] of params) {
|
||||
query[key] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a result to a JSON response.
|
||||
*/
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const parsedObj = routes.convertEntitiesToPojo(obj);
|
||||
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a text response.
|
||||
*/
|
||||
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const body = encoder.encode(text).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser router class that handles route registration and dispatching.
|
||||
*/
|
||||
export class BrowserRouter {
|
||||
private routes: Route[] = [];
|
||||
|
||||
/**
|
||||
* Register a route handler.
|
||||
*/
|
||||
register(method: string, path: string, handler: RouteHandler): void {
|
||||
const { pattern, paramNames } = pathToRegex(path);
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
paramNames,
|
||||
handler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods.
|
||||
*/
|
||||
get(path: string, handler: RouteHandler): void {
|
||||
this.register('GET', path, handler);
|
||||
}
|
||||
|
||||
post(path: string, handler: RouteHandler): void {
|
||||
this.register('POST', path, handler);
|
||||
}
|
||||
|
||||
put(path: string, handler: RouteHandler): void {
|
||||
this.register('PUT', path, handler);
|
||||
}
|
||||
|
||||
patch(path: string, handler: RouteHandler): void {
|
||||
this.register('PATCH', path, handler);
|
||||
}
|
||||
|
||||
delete(path: string, handler: RouteHandler): void {
|
||||
this.register('DELETE', path, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request to the appropriate handler.
|
||||
*/
|
||||
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname;
|
||||
const query = parseQuery(url.search);
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [statusCode, response] = result;
|
||||
return jsonResponse(response, statusCode);
|
||||
}
|
||||
|
||||
// Handle undefined (no content) - 204 should have no body
|
||||
if (result === undefined) {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: null
|
||||
};
|
||||
}
|
||||
|
||||
// Default: JSON response with 200
|
||||
return jsonResponse(result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error into a response.
|
||||
*/
|
||||
private formatError(error: unknown, context: string): BrowserResponse {
|
||||
console.error('[Router] Handler error:', context, error);
|
||||
|
||||
// Check for known error types
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { constructor?: { name?: string }; message?: string };
|
||||
|
||||
if (err.constructor?.name === 'NotFoundError') {
|
||||
return jsonResponse({ message: err.message || 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (err.constructor?.name === 'ValidationError') {
|
||||
return jsonResponse({ message: err.message || 'Validation error' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonResponse({ message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
*/
|
||||
export function createRouter(): BrowserRouter {
|
||||
return new BrowserRouter();
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||
import { getSharedBootstrapItems, routes } from '@triliumnext/core';
|
||||
|
||||
import packageJson from '../../package.json' with { type: 'json' };
|
||||
import { type BrowserRequest,BrowserRouter } from './browser_router';
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
/**
|
||||
* Wraps a core route handler to work with the BrowserRouter.
|
||||
* Core handlers expect an Express-like request object with params, query, and body.
|
||||
*/
|
||||
function wrapHandler(handler: (req: any) => unknown) {
|
||||
return (req: BrowserRequest) => {
|
||||
// Create an Express-like request object
|
||||
const expressLikeReq = {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body
|
||||
};
|
||||
return handler(expressLikeReq);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes on the browser router using the shared builder.
|
||||
*
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
|
||||
apiRoute("get", "/api/search/:searchString", () => []);
|
||||
apiRoute("get", "/api/search-templates", () => []);
|
||||
apiRoute("get", "/api/autocomplete", () => []);
|
||||
}
|
||||
|
||||
function bootstrapRoute() {
|
||||
|
||||
const assetPath = ".";
|
||||
|
||||
return {
|
||||
...getSharedBootstrapItems(assetPath),
|
||||
appPath: assetPath,
|
||||
device: false, // Let the client detect device type.
|
||||
csrfToken: "dummy-csrf-token",
|
||||
themeCssUrl: false,
|
||||
themeUseNextAsBase: "next",
|
||||
triliumVersion: packageJson.version,
|
||||
baseApiUrl: "../api/",
|
||||
headingStyle: "plain",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isDev: import.meta.env.DEV,
|
||||
isMainWindow: true,
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: true,
|
||||
|
||||
// TODO: Fill properly
|
||||
currentLocale: { id: "en", name: "English", rtl: false },
|
||||
isRtl: false,
|
||||
instanceName: null,
|
||||
appCssNoteIds: [],
|
||||
TRILIUM_SAFE_MODE: false
|
||||
} satisfies BootstrapDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a router with all routes registered.
|
||||
*/
|
||||
export function createConfiguredRouter(): BrowserRouter {
|
||||
const router = new BrowserRouter();
|
||||
registerRoutes(router);
|
||||
return router;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private store: Map<string, any> | null = null;
|
||||
|
||||
get<T = any>(key: string): T | undefined {
|
||||
return this.store?.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
if (!this.store) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.store = null;
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
// Create a fresh context for this request
|
||||
const prev = this.store;
|
||||
this.store = new Map();
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise, we need to handle cleanup after it resolves
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.store = prev;
|
||||
}) as T;
|
||||
} else {
|
||||
// Synchronous result, clean up immediately
|
||||
this.store = prev;
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error (for synchronous errors)
|
||||
this.store = prev;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,90 +0,0 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import type { MessagingProvider, MessageHandler } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Messaging provider for browser Worker environments.
|
||||
*
|
||||
* This provider uses the Worker's postMessage API to communicate
|
||||
* with the main thread. It's designed to be used inside a Web Worker
|
||||
* that runs the core services.
|
||||
*
|
||||
* Message flow:
|
||||
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
|
||||
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
|
||||
*/
|
||||
export default class WorkerMessagingProvider implements MessagingProvider {
|
||||
private messageHandlers: MessageHandler[] = [];
|
||||
private isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
// Listen for incoming messages from the main thread
|
||||
self.addEventListener("message", this.handleIncomingMessage);
|
||||
}
|
||||
|
||||
private handleIncomingMessage = (event: MessageEvent) => {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
const { type, message } = event.data || {};
|
||||
|
||||
if (type === "WS_MESSAGE" && message) {
|
||||
// Dispatch to all registered handlers
|
||||
for (const handler of this.messageHandlers) {
|
||||
try {
|
||||
handler(message as WebSocketMessage);
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error in message handler:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to all clients (in this case, the main thread).
|
||||
* The main thread is responsible for further distribution if needed.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage): void {
|
||||
if (this.isDisposed) {
|
||||
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WS_MESSAGE",
|
||||
message
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error sending message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming messages from the main thread.
|
||||
*/
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.push(handler);
|
||||
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected "clients".
|
||||
* In worker context, there's always exactly 1 client (the main thread).
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.isDisposed ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
this.isDisposed = true;
|
||||
self.removeEventListener("message", this.handleIncomingMessage);
|
||||
this.messageHandlers = [];
|
||||
}
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
|
||||
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||
|
||||
import demoDbSql from "./db.sql?raw";
|
||||
|
||||
// Type definitions for SQLite WASM (the library doesn't export these directly)
|
||||
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
|
||||
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
|
||||
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
|
||||
|
||||
/**
|
||||
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
|
||||
* expected by trilium-core.
|
||||
*/
|
||||
class WasmStatement implements Statement {
|
||||
private isRawMode = false;
|
||||
private isPluckMode = false;
|
||||
private isFinalized = false;
|
||||
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database,
|
||||
private sqlite3: Sqlite3Module
|
||||
) {}
|
||||
|
||||
run(...params: unknown[]): RunResult {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call run() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
try {
|
||||
// Use step() and then reset instead of stepFinalize()
|
||||
// This allows the statement to be reused
|
||||
this.stmt.step();
|
||||
const changes = this.db.changes();
|
||||
// Get the last insert row ID using the C API
|
||||
const lastInsertRowid = this.db.pointer ? this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer) : 0;
|
||||
this.stmt.reset();
|
||||
return {
|
||||
changes,
|
||||
lastInsertRowid: typeof lastInsertRowid === "bigint" ? Number(lastInsertRowid) : lastInsertRowid
|
||||
};
|
||||
} catch (e) {
|
||||
// Reset on error to allow reuse
|
||||
this.stmt.reset();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
get(params: unknown): unknown {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call get() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
|
||||
try {
|
||||
if (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value
|
||||
const row = this.stmt.get([]);
|
||||
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
}
|
||||
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
all(...params: unknown[]): unknown[] {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call all() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
while (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value for each row
|
||||
const row = this.stmt.get([]);
|
||||
if (Array.isArray(row) && row.length > 0) {
|
||||
results.push(row[0]);
|
||||
}
|
||||
} else {
|
||||
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
iterate(...params: unknown[]): IterableIterator<unknown> {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call iterate() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const stmt = this.stmt;
|
||||
const isRaw = this.isRawMode;
|
||||
const isPluck = this.isPluckMode;
|
||||
|
||||
return {
|
||||
[Symbol.iterator]() {
|
||||
return this;
|
||||
},
|
||||
next(): IteratorResult<unknown> {
|
||||
if (stmt.step()) {
|
||||
if (isPluck) {
|
||||
const row = stmt.get([]);
|
||||
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
return { value, done: false };
|
||||
}
|
||||
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
|
||||
}
|
||||
stmt.reset();
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
raw(toggleState?: boolean): this {
|
||||
// In raw mode, rows are returned as arrays instead of objects
|
||||
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
|
||||
this.isRawMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
pluck(toggleState?: boolean): this {
|
||||
// In pluck mode, only the first column of each row is returned
|
||||
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
|
||||
this.isPluckMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
private bindParams(params: unknown[]): void {
|
||||
this.stmt.clearBindings();
|
||||
if (params.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single object with named parameters
|
||||
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
|
||||
const inputBindings = params[0] as { [paramName: string]: BindableValue };
|
||||
|
||||
// SQLite WASM expects parameter names to include the prefix (@ : or $)
|
||||
// better-sqlite3 automatically maps unprefixed names to @name
|
||||
// We need to add the @ prefix for compatibility
|
||||
const bindings: { [paramName: string]: BindableValue } = {};
|
||||
for (const [key, value] of Object.entries(inputBindings)) {
|
||||
// If the key already has a prefix, use it as-is
|
||||
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
|
||||
bindings[key] = value;
|
||||
} else {
|
||||
// Add @ prefix to match better-sqlite3 behavior
|
||||
bindings[`@${key}`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.stmt.bind(bindings);
|
||||
} else {
|
||||
// Handle positional parameters - flatten and cast to BindableValue[]
|
||||
const flatParams = params.flat() as BindableValue[];
|
||||
if (flatParams.length > 0) {
|
||||
this.stmt.bind(flatParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
if (!this.isFinalized) {
|
||||
try {
|
||||
this.stmt.finalize();
|
||||
} catch (e) {
|
||||
console.warn("Error finalizing SQLite statement:", e);
|
||||
} finally {
|
||||
this.isFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite database provider for browser environments using SQLite WASM.
|
||||
*
|
||||
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
|
||||
* a DatabaseProvider implementation compatible with trilium-core.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm(); // Initialize SQLite WASM module
|
||||
* provider.loadFromMemory(); // Open an in-memory database
|
||||
* // or
|
||||
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
|
||||
* ```
|
||||
*/
|
||||
export default class BrowserSqlProvider implements DatabaseProvider {
|
||||
private db?: Sqlite3Database;
|
||||
private sqlite3?: Sqlite3Module;
|
||||
private _inTransaction = false;
|
||||
private initPromise?: Promise<void>;
|
||||
private initError?: Error;
|
||||
private statementCache: Map<string, WasmStatement> = new Map();
|
||||
|
||||
// OPFS state tracking
|
||||
private opfsDbPath?: string;
|
||||
|
||||
/**
|
||||
* Get the SQLite WASM module version info.
|
||||
* Returns undefined if the module hasn't been initialized yet.
|
||||
*/
|
||||
get version(): { libVersion: string; sourceId: string } | undefined {
|
||||
return this.sqlite3?.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SQLite WASM module.
|
||||
* This must be called before using any database operations.
|
||||
* Safe to call multiple times - subsequent calls return the same promise.
|
||||
*
|
||||
* @returns A promise that resolves when the module is initialized
|
||||
* @throws Error if initialization fails
|
||||
*/
|
||||
async initWasm(): Promise<void> {
|
||||
// Return existing promise if already initializing/initialized
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Fail fast if we already tried and failed
|
||||
if (this.initError) {
|
||||
throw this.initError;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitWasm();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitWasm(): Promise<void> {
|
||||
try {
|
||||
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
|
||||
const initTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
|
||||
this.sqlite3.version.libVersion
|
||||
);
|
||||
} catch (e) {
|
||||
this.initError = e instanceof Error ? e : new Error(String(e));
|
||||
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
|
||||
throw this.initError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SQLite WASM module has been initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.sqlite3 !== undefined;
|
||||
}
|
||||
|
||||
// ==================== OPFS Support ====================
|
||||
|
||||
/**
|
||||
* Check if the OPFS VFS is available.
|
||||
* This requires:
|
||||
* - Running in a Worker context
|
||||
* - Browser support for OPFS APIs
|
||||
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
|
||||
*
|
||||
* @returns true if OPFS VFS is available for use
|
||||
*/
|
||||
isOpfsAvailable(): boolean {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
|
||||
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
|
||||
return this.sqlite3!.oo1.OpfsDb !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create a database stored in OPFS for persistent storage.
|
||||
* The database will persist across browser sessions.
|
||||
*
|
||||
* Requires COOP/COEP headers to be set by the server:
|
||||
* - Cross-Origin-Opener-Policy: same-origin
|
||||
* - Cross-Origin-Embedder-Policy: require-corp
|
||||
*
|
||||
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
|
||||
* Paths without a leading slash are treated as relative to OPFS root.
|
||||
* Leading directories are created automatically.
|
||||
* @param options - Additional options
|
||||
* @throws Error if OPFS VFS is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm();
|
||||
* if (provider.isOpfsAvailable()) {
|
||||
* provider.loadFromOpfs("/my-database.db");
|
||||
* } else {
|
||||
* console.warn("OPFS not available, using in-memory database");
|
||||
* provider.loadFromMemory();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
|
||||
this.ensureSqlite3();
|
||||
|
||||
if (!this.isOpfsAvailable()) {
|
||||
throw new Error(
|
||||
"OPFS VFS is not available. This requires:\n" +
|
||||
"1. Running in a Worker context\n" +
|
||||
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
|
||||
"3. COOP/COEP headers from the server:\n" +
|
||||
" Cross-Origin-Opener-Policy: same-origin\n" +
|
||||
" Cross-Origin-Embedder-Policy: require-corp"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// OpfsDb automatically creates directories in the path
|
||||
// Mode 'c' = create if not exists
|
||||
const mode = options.createIfNotExists !== false ? 'c' : '';
|
||||
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
|
||||
this.opfsDbPath = path;
|
||||
|
||||
// Configure the database for OPFS
|
||||
// Note: WAL mode requires exclusive locking in OPFS environment
|
||||
this.db.exec("PRAGMA journal_mode = DELETE");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently open database is stored in OPFS.
|
||||
*/
|
||||
get isUsingOpfs(): boolean {
|
||||
return this.opfsDbPath !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OPFS path of the currently open database.
|
||||
* Returns undefined if not using OPFS.
|
||||
*/
|
||||
get currentOpfsPath(): string | undefined {
|
||||
return this.opfsDbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database has been initialized with a schema.
|
||||
* This is a simple sanity check that looks for the existence of core tables.
|
||||
*
|
||||
* @returns true if the database appears to be initialized
|
||||
*/
|
||||
isDbInitialized(): boolean {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
|
||||
const tableExists = this.db!.selectValue(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
|
||||
);
|
||||
|
||||
return tableExists !== undefined;
|
||||
}
|
||||
|
||||
// ==================== End OPFS Support ====================
|
||||
|
||||
loadFromFile(_path: string, _isReadOnly: boolean): void {
|
||||
// Browser environment doesn't have direct file system access.
|
||||
// Use OPFS for persistent storage.
|
||||
throw new Error(
|
||||
"loadFromFile is not supported in browser environment. " +
|
||||
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||
"or loadFromOpfs() for persistent storage."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty in-memory database.
|
||||
* Data will be lost when the page is closed.
|
||||
*
|
||||
* For persistent storage, use loadFromOpfs() instead.
|
||||
* To load demo data, call initializeDemoDatabase() after this.
|
||||
*/
|
||||
loadFromMemory(): void {
|
||||
this.ensureSqlite3();
|
||||
console.log("[BrowserSqlProvider] Creating in-memory database...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
// Initialize with demo data for in-memory databases
|
||||
// (since they won't persist anyway)
|
||||
this.initializeDemoDatabase();
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with demo/starter data.
|
||||
* This should only be called once when creating a new database.
|
||||
*
|
||||
* For OPFS databases, this is called automatically only if the database
|
||||
* doesn't already exist.
|
||||
*/
|
||||
initializeDemoDatabase(): void {
|
||||
this.ensureDb();
|
||||
console.log("[BrowserSqlProvider] Initializing database with demo data...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db!.exec(demoDbSql);
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
loadFromBuffer(buffer: Uint8Array): void {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM can deserialize a database from a byte array
|
||||
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
|
||||
try {
|
||||
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
|
||||
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||
this.db.pointer!,
|
||||
"main",
|
||||
p,
|
||||
buffer.byteLength,
|
||||
buffer.byteLength,
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
|
||||
);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`Failed to deserialize database: ${rc}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.sqlite3!.wasm.dealloc(p);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
backup(_destinationFile: string): void {
|
||||
// In browser, we can serialize the database to a byte array
|
||||
// For actual file backup, we'd need to use File System Access API or download
|
||||
throw new Error(
|
||||
"backup to file is not supported in browser environment. " +
|
||||
"Use serialize() to get the database as a Uint8Array instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the database to a byte array.
|
||||
* This can be used to save the database to IndexedDB, download it, etc.
|
||||
*/
|
||||
serialize(): Uint8Array {
|
||||
this.ensureDb();
|
||||
// Use the convenience wrapper which handles all the memory management
|
||||
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
|
||||
}
|
||||
|
||||
prepare(query: string): Statement {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if we already have this statement cached
|
||||
if (this.statementCache.has(query)) {
|
||||
return this.statementCache.get(query)!;
|
||||
}
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
transaction<T>(func: (statement: Statement) => T): Transaction {
|
||||
this.ensureDb();
|
||||
|
||||
const self = this;
|
||||
let savepointCounter = 0;
|
||||
|
||||
// Helper function to execute within a transaction
|
||||
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
|
||||
// If we're already in a transaction, use SAVEPOINTs for nesting
|
||||
// This mimics better-sqlite3's behavior
|
||||
if (self._inTransaction) {
|
||||
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
|
||||
self.db!.exec(`SAVEPOINT ${savepointName}`);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in a transaction, start a new one
|
||||
self._inTransaction = true;
|
||||
self.db!.exec(beginStatement);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
self._inTransaction = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the transaction function that acts like better-sqlite3's Transaction interface
|
||||
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
|
||||
const transactionWrapper = Object.assign(
|
||||
// Default call executes with BEGIN (same as immediate)
|
||||
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
|
||||
{
|
||||
// Deferred transaction - locks acquired on first data access
|
||||
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
|
||||
// Immediate transaction - acquires write lock immediately
|
||||
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
|
||||
// Exclusive transaction - exclusive lock
|
||||
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
|
||||
// Default is same as calling directly
|
||||
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
|
||||
}
|
||||
);
|
||||
|
||||
return transactionWrapper as unknown as Transaction;
|
||||
}
|
||||
|
||||
get inTransaction(): boolean {
|
||||
return this._inTransaction;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.ensureDb();
|
||||
this.db!.exec(query);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clean up all cached statements first
|
||||
for (const statement of this.statementCache.values()) {
|
||||
try {
|
||||
statement.finalize();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error finalizing statement during cleanup:", e);
|
||||
}
|
||||
}
|
||||
this.statementCache.clear();
|
||||
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
// Reset OPFS state
|
||||
this.opfsDbPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
|
||||
*/
|
||||
changes(): number {
|
||||
this.ensureDb();
|
||||
return this.db!.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is currently open.
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.db !== undefined && this.db.isOpen();
|
||||
}
|
||||
|
||||
private ensureSqlite3(): void {
|
||||
if (!this.sqlite3) {
|
||||
throw new Error(
|
||||
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDb(): void {
|
||||
this.ensureSqlite3();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import i18next from "i18next";
|
||||
import I18NextHttpBackend from "i18next-http-backend";
|
||||
|
||||
export default async function translationProvider(locale: LOCALE_IDS) {
|
||||
await i18next.use(I18NextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import LocalServerWorker from "./local-server-worker?worker";
|
||||
let localWorker: Worker | null = null;
|
||||
const pending = new Map();
|
||||
|
||||
export function startLocalServerWorker() {
|
||||
if (localWorker) return localWorker;
|
||||
localWorker = new LocalServerWorker();
|
||||
|
||||
// Handle worker errors during initialization
|
||||
localWorker.onerror = (event) => {
|
||||
console.error("[LocalBridge] Worker error:", event);
|
||||
// Reject all pending requests
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(`Worker error: ${event.message}`));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
localWorker.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
|
||||
// Handle worker error reports
|
||||
if (msg?.type === "WORKER_ERROR") {
|
||||
console.error("[LocalBridge] Worker reported error:", msg.error);
|
||||
// Reject all pending requests with the error
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
|
||||
}
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
|
||||
|
||||
const { id, response, error } = msg;
|
||||
const resolver = pending.get(id);
|
||||
if (!resolver) return;
|
||||
pending.delete(id);
|
||||
|
||||
if (error) resolver.reject(new Error(error));
|
||||
else resolver.resolve(response);
|
||||
};
|
||||
|
||||
return localWorker;
|
||||
}
|
||||
|
||||
export function attachServiceWorkerBridge() {
|
||||
navigator.serviceWorker.addEventListener("message", async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_FETCH") return;
|
||||
|
||||
const port = event.ports && event.ports[0];
|
||||
if (!port) return;
|
||||
|
||||
try {
|
||||
startLocalServerWorker();
|
||||
|
||||
const id = msg.id;
|
||||
const req = msg.request;
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
// Transfer body to worker for efficiency (if present)
|
||||
localWorker.postMessage({
|
||||
type: "LOCAL_REQUEST",
|
||||
id,
|
||||
request: req
|
||||
}, req.body ? [req.body] : []);
|
||||
});
|
||||
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, response.body ? [response.body] : []);
|
||||
} catch (e) {
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id: msg.id,
|
||||
response: {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
body: new TextEncoder().encode(String(e?.message || e)).buffer
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { BrowserRouter } from './lightweight/browser_router';
|
||||
import { createConfiguredRouter } from './lightweight/browser_routes';
|
||||
import BrowserExecutionContext from './lightweight/cls_provider';
|
||||
import BrowserCryptoProvider from './lightweight/crypto_provider';
|
||||
import WorkerMessagingProvider from './lightweight/messaging_provider';
|
||||
import BrowserSqlProvider from './lightweight/sql_provider';
|
||||
import translationProvider from './lightweight/translation_provider';
|
||||
|
||||
// Global error handlers - MUST be set up before any async imports
|
||||
self.onerror = (message, source, lineno, colno, error) => {
|
||||
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
|
||||
// Try to notify the main thread about the error
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(message),
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
stack: error?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Can't even post message, just log
|
||||
console.error("[Worker] Failed to report error:", e);
|
||||
}
|
||||
return false; // Don't suppress the error
|
||||
};
|
||||
|
||||
self.onunhandledrejection = (event) => {
|
||||
console.error("[Worker] Unhandled rejection:", event.reason);
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(event.reason?.message || event.reason),
|
||||
stack: event.reason?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Worker] Failed to report rejection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[Worker] Error handlers installed");
|
||||
|
||||
// Shared SQL provider instance
|
||||
const sqlProvider = new BrowserSqlProvider();
|
||||
|
||||
// Messaging provider for worker-to-main-thread communication
|
||||
const messagingProvider = new WorkerMessagingProvider();
|
||||
|
||||
// Core module, router, and initialization state
|
||||
let coreModule: typeof import("@triliumnext/core") | null = null;
|
||||
let router: BrowserRouter | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Initialize SQLite WASM and load the core module.
|
||||
* This happens once at worker startup.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initPromise) {
|
||||
return initPromise; // Already initializing
|
||||
}
|
||||
if (initError) {
|
||||
throw initError; // Failed before, don't retry
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log("[Worker] Initializing SQLite WASM...");
|
||||
await sqlProvider.initWasm();
|
||||
|
||||
// Try to use OPFS for persistent storage
|
||||
if (sqlProvider.isOpfsAvailable()) {
|
||||
console.log("[Worker] OPFS available, loading persistent database...");
|
||||
sqlProvider.loadFromOpfs("/trilium.db");
|
||||
|
||||
// Check if database is initialized (schema exists)
|
||||
if (!sqlProvider.isDbInitialized()) {
|
||||
console.log("[Worker] Database not initialized, loading demo data...");
|
||||
sqlProvider.initializeDemoDatabase();
|
||||
console.log("[Worker] Demo data loaded");
|
||||
} else {
|
||||
console.log("[Worker] Existing initialized database loaded");
|
||||
}
|
||||
} else {
|
||||
// Fall back to in-memory database (non-persistent)
|
||||
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
|
||||
sqlProvider.loadFromMemory();
|
||||
}
|
||||
|
||||
console.log("[Worker] Database loaded");
|
||||
|
||||
console.log("[Worker] Loading @triliumnext/core...");
|
||||
coreModule = await import("@triliumnext/core");
|
||||
await coreModule.initializeCore({
|
||||
executionContext: new BrowserExecutionContext(),
|
||||
crypto: new BrowserCryptoProvider(),
|
||||
messaging: messagingProvider,
|
||||
translations: translationProvider,
|
||||
dbConfig: {
|
||||
provider: sqlProvider,
|
||||
isReadOnly: false,
|
||||
onTransactionCommit: () => {
|
||||
// No-op for now
|
||||
},
|
||||
onTransactionRollback: () => {
|
||||
// No-op for now
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
|
||||
|
||||
// Create and configure the router
|
||||
router = createConfiguredRouter();
|
||||
console.log("[Worker] Router configured");
|
||||
|
||||
console.log("[Worker] Initializing becca...");
|
||||
await coreModule.becca_loader.beccaLoaded;
|
||||
|
||||
console.log("[Worker] Initialization complete");
|
||||
} catch (error) {
|
||||
initError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error("[Worker] Initialization failed:", initError);
|
||||
throw initError;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker is initialized before processing requests.
|
||||
* Returns the router if initialization was successful.
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
await initialize();
|
||||
if (!router) {
|
||||
throw new Error("Router not initialized");
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
interface LocalRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Main dispatch
|
||||
async function dispatch(request: LocalRequest) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
console.log("[Worker] Dispatch:", url.pathname);
|
||||
|
||||
// Ensure initialization is complete and get the router
|
||||
const appRouter = await ensureInitialized();
|
||||
|
||||
// Dispatch to the router
|
||||
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
|
||||
}
|
||||
|
||||
// Start initialization immediately when the worker loads
|
||||
console.log("[Worker] Starting initialization...");
|
||||
initialize().catch(err => {
|
||||
console.error("[Worker] Initialization failed:", err);
|
||||
// Post error to main thread
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: err?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_REQUEST") return;
|
||||
|
||||
const { id, request } = msg;
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
|
||||
// Transfer body back (if any) - use options object for proper typing
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, { transfer: response.body ? [response.body] : [] });
|
||||
} catch (e) {
|
||||
console.error("[Worker] Dispatch error:", e);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
error: String((e as Error)?.message || e)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -1,185 +0,0 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
31
apps/client-standalone/src/vite-env.d.ts
vendored
@@ -1,31 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import prefresh from '@prefresh/vite';
|
||||
import { join } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: clientAssets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../server/src/assets/*",
|
||||
dest: "server-assets"
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [
|
||||
prefresh(),
|
||||
clientWatchPlugin()
|
||||
] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsxDev: isDev
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
devSourcemap: isDev
|
||||
},
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -13,6 +13,7 @@
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<div id="context-menu-cover"></div>
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required to match the PWA's top bar color with the theme -->
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.6.1",
|
||||
"@preact/signals": "2.6.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -43,7 +43,7 @@
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"globals": "17.3.0",
|
||||
"i18next": "25.8.0",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
@@ -56,12 +56,12 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.6.1",
|
||||
"mind-elixir": "5.7.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.2",
|
||||
"preact": "10.28.3",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-window": "2.2.5",
|
||||
"react-window": "2.2.6",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.4.0",
|
||||
"happy-dom": "20.5.0",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
|
||||
@@ -99,15 +99,22 @@ function initFullScreenDetection(currentWindow: Electron.BrowserWindow) {
|
||||
}
|
||||
|
||||
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
|
||||
const material = style.getPropertyValue("--background-material").trim();
|
||||
if (window.glob.platform === "win32") {
|
||||
const material = style.getPropertyValue("--background-material");
|
||||
// TriliumNextTODO: find a nicer way to make TypeScript happy – unfortunately TS did not like Array.includes here
|
||||
const bgMaterialOptions = ["auto", "none", "mica", "acrylic", "tabbed"] as const;
|
||||
const foundBgMaterialOption = bgMaterialOptions.find((bgMaterialOption) => material === bgMaterialOption);
|
||||
if (foundBgMaterialOption) {
|
||||
currentWindow.setBackgroundMaterial(foundBgMaterialOption);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.glob.platform === "darwin") {
|
||||
const bgMaterialOptions = [ "popover", "tooltip", "titlebar", "selection", "menu", "sidebar", "header", "sheet", "window", "hud", "fullscreen-ui", "content", "under-window", "under-page" ] as const;
|
||||
const foundBgMaterialOption = bgMaterialOptions.find((bgMaterialOption) => material === bgMaterialOption);
|
||||
if (foundBgMaterialOption) {
|
||||
currentWindow.setVibrancy(foundBgMaterialOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,37 +36,10 @@ async function setupGlob() {
|
||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null,
|
||||
device: json.device || getDevice()
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice() {
|
||||
// Respect user's manual override via URL.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has("print")) {
|
||||
return "print";
|
||||
} else if (urlParams.has("desktop")) {
|
||||
return "desktop";
|
||||
} else if (urlParams.has("mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||
return isMobile() ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/73731646/944162
|
||||
function isMobile() {
|
||||
const mQ = matchMedia?.("(pointer:coarse)");
|
||||
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||
|
||||
if ("orientation" in window) return true;
|
||||
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||
return userAgentsRegEx.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
// We have to selectively import Bootstrap CSS based on text direction.
|
||||
if (glob.isRtl) {
|
||||
|
||||
72
apps/client/src/layouts/mobile_layout.css
Normal file
72
apps/client/src/layouts/mobile_layout.css
Normal file
@@ -0,0 +1,72 @@
|
||||
span.keyboard-shortcut,
|
||||
kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.25em;
|
||||
padding-inline-start: 0.5em;
|
||||
padding-inline-end: 0.5em;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
.quick-search {
|
||||
margin: 0;
|
||||
}
|
||||
.quick-search .dropdown-menu {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
/* #region Tree */
|
||||
.tree-wrapper {
|
||||
max-height: 100%;
|
||||
margin-top: 0px;
|
||||
overflow-y: auto;
|
||||
contain: content;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.fancytree-title {
|
||||
margin-inline-start: 0.6em !important;
|
||||
}
|
||||
|
||||
.fancytree-node {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
span.fancytree-expander {
|
||||
width: 24px !important;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.fancytree-loading span.fancytree-expander {
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.fancytree-loading span.fancytree-expander:after {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 4px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.tree-wrapper .collapse-tree-button,
|
||||
.tree-wrapper .scroll-to-active-note-button,
|
||||
.tree-wrapper .tree-settings-button {
|
||||
position: fixed;
|
||||
margin-inline-end: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tree-wrapper .unhoist-button {
|
||||
font-size: 200%;
|
||||
}
|
||||
/* #endregion */
|
||||
@@ -1,128 +1,41 @@
|
||||
import "./mobile_layout.css";
|
||||
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import ContentHeader from "../widgets/containers/content_header.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
span.keyboard-shortcut,
|
||||
kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.25em;
|
||||
padding-inline-start: 0.5em;
|
||||
padding-inline-end: 0.5em;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
.quick-search {
|
||||
margin: 0;
|
||||
}
|
||||
.quick-search .dropdown-menu {
|
||||
max-width: 350px;
|
||||
}
|
||||
</style>`;
|
||||
|
||||
const FANCYTREE_CSS = `
|
||||
<style>
|
||||
.tree-wrapper {
|
||||
max-height: 100%;
|
||||
margin-top: 0px;
|
||||
overflow-y: auto;
|
||||
contain: content;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.fancytree-custom-icon {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.fancytree-title {
|
||||
font-size: 1.5em;
|
||||
margin-inline-start: 0.6em !important;
|
||||
}
|
||||
|
||||
.fancytree-node {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.fancytree-node .fancytree-expander:before {
|
||||
font-size: 2em !important;
|
||||
}
|
||||
|
||||
span.fancytree-expander {
|
||||
width: 24px !important;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.fancytree-loading span.fancytree-expander {
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.fancytree-loading span.fancytree-expander:after {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 4px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.tree-wrapper .collapse-tree-button,
|
||||
.tree-wrapper .scroll-to-active-note-button,
|
||||
.tree-wrapper .tree-settings-button {
|
||||
position: fixed;
|
||||
margin-inline-end: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tree-wrapper .unhoist-button {
|
||||
font-size: 200%;
|
||||
}
|
||||
</style>`;
|
||||
|
||||
export default class MobileLayout {
|
||||
getRootWidget(appContext: typeof AppContext) {
|
||||
const rootContainer = new RootContainer(true)
|
||||
.setParent(appContext)
|
||||
.class("horizontal-layout")
|
||||
.cssBlock(MOBILE_CSS)
|
||||
.child(new FlexContainer("column").id("mobile-sidebar-container"))
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
@@ -136,7 +49,7 @@ export default class MobileLayout {
|
||||
.css("padding-inline-start", "0")
|
||||
.css("padding-inline-end", "0")
|
||||
.css("contain", "content")
|
||||
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
|
||||
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget()))
|
||||
)
|
||||
.child(
|
||||
new ScreenContainer("detail", "row")
|
||||
@@ -147,30 +60,28 @@ export default class MobileLayout {
|
||||
new NoteWrapperWidget()
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
.contentSized()
|
||||
.css("font-size", "larger")
|
||||
.css("align-items", "center")
|
||||
.child(<ToggleSidebarButton />)
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(<NoteBadges />)
|
||||
.child(<MobileDetailMenu />)
|
||||
)
|
||||
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
||||
.child(<PromotedAttributes />)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new ContentHeader()
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfoWidget />)
|
||||
)
|
||||
.child(<InlineTitle />)
|
||||
.child(<NoteTitleActions />)
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
||||
.child(<SearchResult />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
.child(new FindWidget())
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -179,7 +90,6 @@ export default class MobileLayout {
|
||||
new FlexContainer("column")
|
||||
.contentSized()
|
||||
.id("mobile-bottom-bar")
|
||||
.child(new TabRowWidget().css("height", "40px"))
|
||||
.child(new FlexContainer("row")
|
||||
.class("horizontal")
|
||||
.css("height", "53px")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { KeyboardActionNames } from "@triliumnext/commons";
|
||||
import { h, JSX, render } from "preact";
|
||||
|
||||
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
|
||||
import note_tooltip from "../services/note_tooltip.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { h, JSX, render } from "preact";
|
||||
|
||||
export interface ContextMenuOptions<T> {
|
||||
x: number;
|
||||
@@ -62,17 +63,17 @@ export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEve
|
||||
|
||||
class ContextMenu {
|
||||
private $widget: JQuery<HTMLElement>;
|
||||
private $cover: JQuery<HTMLElement>;
|
||||
private $cover?: JQuery<HTMLElement>;
|
||||
private options?: ContextMenuOptions<any>;
|
||||
private isMobile: boolean;
|
||||
|
||||
constructor() {
|
||||
this.$widget = $("#context-menu-container");
|
||||
this.$cover = $("#context-menu-cover");
|
||||
this.$widget.addClass("dropend");
|
||||
this.isMobile = utils.isMobile();
|
||||
|
||||
if (this.isMobile) {
|
||||
this.$cover = $("#context-menu-cover");
|
||||
this.$cover.on("click", () => this.hide());
|
||||
} else {
|
||||
$(document).on("click", (e) => this.hide());
|
||||
@@ -91,7 +92,7 @@ class ContextMenu {
|
||||
}
|
||||
|
||||
this.$widget.toggleClass("mobile-bottom-menu", !this.options.forcePositionOnMobile);
|
||||
this.$cover.addClass("show");
|
||||
this.$cover?.addClass("show");
|
||||
$("body").addClass("context-menu-shown");
|
||||
|
||||
this.$widget.empty();
|
||||
@@ -140,16 +141,14 @@ class ContextMenu {
|
||||
} else {
|
||||
left = this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
} else if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
|
||||
// Overflow: right
|
||||
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
|
||||
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
|
||||
// Overflow: left
|
||||
left = CONTEXT_MENU_PADDING;
|
||||
} else {
|
||||
if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
|
||||
// Overflow: right
|
||||
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
|
||||
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
|
||||
// Overflow: left
|
||||
left = CONTEXT_MENU_PADDING;
|
||||
} else {
|
||||
left = this.options.x - CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
left = this.options.x - CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
|
||||
this.$widget
|
||||
@@ -249,7 +248,7 @@ class ContextMenu {
|
||||
if ("uiIcon" in item || "checked" in item) {
|
||||
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
|
||||
if (icon) {
|
||||
$icon.addClass(icon);
|
||||
$icon.addClass([icon, "tn-icon"]);
|
||||
} else {
|
||||
$icon.append(" ");
|
||||
}
|
||||
@@ -261,7 +260,7 @@ class ContextMenu {
|
||||
.append(item.title);
|
||||
|
||||
if ("badges" in item && item.badges) {
|
||||
for (let badge of item.badges) {
|
||||
for (const badge of item.badges) {
|
||||
const badgeElement = $(`<span class="badge">`).text(badge.title);
|
||||
|
||||
if (badge.className) {
|
||||
@@ -352,7 +351,7 @@ class ContextMenu {
|
||||
async hide() {
|
||||
this.options?.onHide?.();
|
||||
this.$widget.removeClass("show");
|
||||
this.$cover.removeClass("show");
|
||||
this.$cover?.removeClass("show");
|
||||
$("body").removeClass("context-menu-shown");
|
||||
this.$widget.hide();
|
||||
}
|
||||
|
||||
9
apps/client/src/services/content_renderer.css
Normal file
9
apps/client/src/services/content_renderer.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.rendered-content.no-preview > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 500%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./content_renderer.css";
|
||||
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
|
||||
@@ -71,18 +73,9 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
|
||||
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
|
||||
} else if (entity instanceof FNote) {
|
||||
$renderedContent
|
||||
.css("display", "flex")
|
||||
.css("flex-direction", "column");
|
||||
$renderedContent.addClass("no-preview");
|
||||
$renderedContent.append(
|
||||
$("<div>")
|
||||
.css("display", "flex")
|
||||
.css("justify-content", "space-around")
|
||||
.css("align-items", "center")
|
||||
.css("height", "100%")
|
||||
.css("font-size", "500%")
|
||||
.css("flex-grow", "1")
|
||||
.append($("<span>").addClass(entity.getIcon()))
|
||||
$("<div>").append($("<span>").addClass(entity.getIcon()))
|
||||
);
|
||||
|
||||
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
|
||||
|
||||
@@ -49,7 +49,7 @@ function createClassForColor(colorString: string | null) {
|
||||
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue");
|
||||
}
|
||||
|
||||
function parseColor(color: string) {
|
||||
export function parseColor(color: string) {
|
||||
try {
|
||||
return Color(color.toLowerCase());
|
||||
} catch (ex) {
|
||||
@@ -77,7 +77,7 @@ function adjustColorLightness(color: ColorInstance, lightThemeMaxLightness: numb
|
||||
}
|
||||
|
||||
/** Returns the hue of the specified color, or undefined if the color is grayscale. */
|
||||
function getHue(color: ColorInstance) {
|
||||
export function getHue(color: ColorInstance) {
|
||||
const hslColor = color.hsl();
|
||||
if (hslColor.saturationl() > 0) {
|
||||
return hslColor.hue();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
const docName = note.getLabelValue("docName");
|
||||
let docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content);
|
||||
await processContent(fallbackUrl, $content)
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((_i, el) => {
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,17 +51,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
function getBasePath() {
|
||||
if (window.glob.isStandalone) {
|
||||
return `server-assets`;
|
||||
}
|
||||
if (window.glob.isDev) {
|
||||
return `${window.glob.assetPath }/..`;
|
||||
}
|
||||
return window.glob.assetPath;
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "./i18n";
|
||||
import options from "./options";
|
||||
import { isMobile } from "./utils";
|
||||
|
||||
export interface ExperimentalFeature {
|
||||
id: string;
|
||||
@@ -21,7 +22,7 @@ let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
|
||||
|
||||
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
|
||||
if (featureId === "new-layout") {
|
||||
return options.is("newLayout");
|
||||
return (isMobile() || options.is("newLayout"));
|
||||
}
|
||||
|
||||
return getEnabledFeatures().has(featureId);
|
||||
@@ -29,7 +30,7 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId):
|
||||
|
||||
export function getEnabledExperimentalFeatureIds() {
|
||||
const values = [ ...getEnabledFeatures().values() ];
|
||||
if (options.is("newLayout")) {
|
||||
if (isMobile() || options.is("newLayout")) {
|
||||
values.push("new-layout");
|
||||
}
|
||||
return values;
|
||||
|
||||
@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
|
||||
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
|
||||
|
||||
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
|
||||
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
|
||||
const notesCount = await server.get<number>(`autocomplete/notesCount`);
|
||||
let debounceTimeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function getSearchDelay(notesCount: number): number {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
|
||||
@@ -133,8 +133,6 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -816,7 +814,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ async function sendPing() {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (glob.device === "print" || glob.isStandalone) return;
|
||||
if (glob.device === "print") return;
|
||||
|
||||
ws = connectWebSocket();
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
--bs-body-color: var(--main-text-color) !important;
|
||||
--bs-body-bg: var(--main-background-color) !important;
|
||||
--ck-mention-list-max-height: 500px;
|
||||
--tn-modal-max-height: 90vh;
|
||||
--tn-modal-max-height: 90svh;
|
||||
|
||||
--tree-item-light-theme-max-color-lightness: 50;
|
||||
--tree-item-dark-theme-min-color-lightness: 75;
|
||||
@@ -111,6 +111,7 @@ body.mobile #root-widget.virtual-keyboard-opened #mobile-bottom-bar {
|
||||
}
|
||||
|
||||
#mobile-bottom-bar {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding-bottom: var(--mobile-bottom-offset);
|
||||
}
|
||||
|
||||
@@ -224,10 +225,6 @@ body.mobile .modal .modal-dialog {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.mobile .modal .modal-content {
|
||||
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
|
||||
}
|
||||
|
||||
.component {
|
||||
contain: size;
|
||||
}
|
||||
@@ -413,6 +410,7 @@ body.desktop .tabulator-popup-container,
|
||||
|
||||
.dropdown-menu.static {
|
||||
box-shadow: unset;
|
||||
backdrop-filter: unset !important;
|
||||
}
|
||||
|
||||
.dropend .dropdown-toggle::after {
|
||||
@@ -458,7 +456,7 @@ body.desktop .tabulator-popup-container,
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
|
||||
.dropdown-menu:not(#context-menu-container) .dropdown-item,
|
||||
body.desktop .dropdown-menu .dropdown-toggle,
|
||||
body #context-menu-container .dropdown-item > span,
|
||||
body.mobile .dropdown .dropdown-submenu > span {
|
||||
@@ -466,6 +464,15 @@ body.mobile .dropdown .dropdown-submenu > span {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
body.mobile .dropdown .dropdown-submenu {
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item span.keyboard-shortcut,
|
||||
.dropdown-item *:not(.keyboard-shortcut) > kbd {
|
||||
flex-grow: 1;
|
||||
@@ -1255,7 +1262,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
z-index: 2500;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1534,7 +1541,8 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
|
||||
@media (max-width: 991px) {
|
||||
body.mobile #launcher-pane .dropdown.global-menu > .dropdown-menu.show,
|
||||
body.mobile #launcher-container .dropdown > .dropdown-menu.show {
|
||||
body.mobile #launcher-container .dropdown > .dropdown-menu.show,
|
||||
body.mobile .dropdown-menu.mobile-bottom-menu.show {
|
||||
--dropdown-bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
|
||||
position: fixed !important;
|
||||
bottom: var(--dropdown-bottom) !important;
|
||||
@@ -1546,6 +1554,11 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
max-height: calc(var(--tn-modal-max-height) - var(--dropdown-bottom));
|
||||
}
|
||||
|
||||
body.mobile .dropdown-menu.mobile-bottom-menu.show {
|
||||
--dropdown-bottom: 0px;
|
||||
padding-bottom: calc(max(var(--menu-padding-size), env(safe-area-inset-bottom))) !important;
|
||||
}
|
||||
|
||||
#mobile-sidebar-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1614,6 +1627,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
|
||||
body.mobile .modal-content {
|
||||
overflow-y: auto;
|
||||
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
|
||||
}
|
||||
|
||||
body.mobile .modal-footer {
|
||||
@@ -1669,39 +1683,16 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
#detail-container {
|
||||
background: var(--main-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
body.mobile.force-fixed-tree #mobile-sidebar-wrapper {
|
||||
padding-top: 0;
|
||||
position: static;
|
||||
height: 40vh;
|
||||
width: 100vw;
|
||||
transform: none !important;
|
||||
background-color: var(--left-pane-background-color) !important;
|
||||
border-bottom: 0.5px solid var(--main-border-color);
|
||||
}
|
||||
body.mobile {
|
||||
.modal-dialog {
|
||||
margin: var(--bs-modal-margin);
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
body.mobile.force-fixed-tree #mobile-sidebar-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mobile.force-fixed-tree #mobile-sidebar-wrapper .quick-search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile.force-fixed-tree .component > button.bx-sidebar {
|
||||
visibility: hidden;
|
||||
padding: 0;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
body.mobile.force-fixed-tree #mobile-rest-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
body.mobile.force-fixed-tree #detail-container {
|
||||
flex-grow: 1;
|
||||
.modal-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2617,14 +2608,14 @@ iframe.print-iframe {
|
||||
}
|
||||
}
|
||||
|
||||
#root-widget.virtual-keyboard-opened .note-split:not(:focus-within) {
|
||||
#root-widget.virtual-keyboard-opened .note-split:not(.active) {
|
||||
max-height: 80px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.desktop .title-row {
|
||||
.title-row {
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
--left-pane-collapsed-border-color: #0009;
|
||||
--left-pane-background-color: #1f1f1f;
|
||||
--left-pane-text-color: #aaaaaa;
|
||||
--left-pane-icon-color: #c5c5c5;
|
||||
--left-pane-item-hover-background: #ffffff0d;
|
||||
--left-pane-item-selected-background: #ffffff25;
|
||||
--left-pane-item-selected-color: #dfdfdf;
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
--left-pane-collapsed-border-color: #0000000d;
|
||||
--left-pane-background-color: #f2f2f2;
|
||||
--left-pane-text-color: #383838;
|
||||
--left-pane-icon-color: currentColor;
|
||||
--left-pane-item-hover-background: rgba(0, 0, 0, 0.032);
|
||||
--left-pane-item-selected-background: white;
|
||||
--left-pane-item-selected-color: black;
|
||||
|
||||
@@ -47,9 +47,14 @@
|
||||
}
|
||||
|
||||
/* The toolbar show / hide button for the current text block */
|
||||
.ck.ck-block-toolbar-button {
|
||||
:root .ck.ck-block-toolbar-button {
|
||||
--ck-color-block-toolbar-button: var(--muted-text-color);
|
||||
--ck-color-button-on-background: transparent;
|
||||
--ck-color-button-on-color: currentColor;
|
||||
--ck-color-button-on-color: var(--ck-editor-toolbar-button-on-color);
|
||||
translate: -40% 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
:root .ck.ck-toolbar .ck-button:not(.ck-disabled):active,
|
||||
@@ -517,6 +522,10 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel).ck
|
||||
* EDITOR'S CONTENT
|
||||
*/
|
||||
|
||||
.note-detail-editable-text-editor > .ck-placeholder {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/*
|
||||
* Code Blocks
|
||||
*/
|
||||
|
||||
@@ -57,12 +57,12 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/*
|
||||
/*
|
||||
* SEARCH PAGE
|
||||
*/
|
||||
|
||||
/* Button bar */
|
||||
.search-definition-widget .search-setting-table tbody:last-child div {
|
||||
.search-definition-widget .search-setting-table .search-actions-container {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -143,7 +143,7 @@
|
||||
/*
|
||||
* OPTIONS PAGES
|
||||
*/
|
||||
|
||||
|
||||
:root {
|
||||
--options-card-min-width: 500px;
|
||||
--options-card-max-width: 900px;
|
||||
@@ -156,6 +156,10 @@
|
||||
--preferred-max-content-width: var(--options-card-max-width);
|
||||
}
|
||||
|
||||
.note-split.options .collection-properties {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Create a gap at the top of the option pages */
|
||||
.note-detail-content-widget-content.options>*:first-child {
|
||||
margin-top: var(--options-first-item-top-margin, 1em);
|
||||
@@ -331,4 +335,4 @@ nav.options-section-tabs + .options-section {
|
||||
|
||||
.etapi-options-section div {
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +40,30 @@ body.mobile {
|
||||
|
||||
/* #region Mica */
|
||||
|
||||
/* Quirk: --background-material is read before "theme-supports-background-effects" class
|
||||
* is applied. Apply the matterial even if the theme doesn't support it. */
|
||||
body.background-effects.platform-win32 {
|
||||
/* Quirk: --background-material is read before "theme-supports-background-effects" class
|
||||
* is applied. Apply the matterial even if the theme doesn't support it. */
|
||||
--background-material: tabbed;
|
||||
&.layout-vertical {
|
||||
--background-material: mica;
|
||||
}
|
||||
|
||||
&.layout-horizontal {
|
||||
--background-material: tabbed;
|
||||
}
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 {
|
||||
body.background-effects.platform-darwin {
|
||||
/** Reference: https://developer.apple.com/documentation/appkit/nsvisualeffectview?preferredLanguage=objc **/
|
||||
&.layout-vertical {
|
||||
--background-material: under-window;
|
||||
}
|
||||
|
||||
&.layout-horizontal {
|
||||
--background-material: hud;
|
||||
}
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects {
|
||||
--launcher-pane-horiz-border-color: var(--launcher-pane-horiz-border-color-bgfx);
|
||||
--launcher-pane-horiz-background-color: var(--launcher-pane-horiz-background-color-bgfx);
|
||||
--launcher-pane-vert-background-color: var(--launcher-pane-vert-background-color-bgfx);
|
||||
@@ -56,33 +73,29 @@ body.background-effects.theme-supports-background-effects.platform-win32 {
|
||||
--root-background: transparent;
|
||||
}
|
||||
|
||||
body.background-effects.platform-win32.layout-vertical {
|
||||
--background-material: mica;
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-vertical {
|
||||
body.background-effects.theme-supports-background-effects.layout-vertical {
|
||||
--left-pane-background-color: var(--window-background-color-bgfx);
|
||||
--center-pane-background-color-bgfx: var(--center-pane-vert-layout-background-color-bgfx);
|
||||
--right-pane-background-color: var(--right-pane-background-color-bgfx);
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-horizontal {
|
||||
body.background-effects.theme-supports-background-effects.layout-horizontal {
|
||||
--center-pane-background-color-bgfx: var(--center-pane-horiz-layout-background-color-bgfx);
|
||||
--gutter-color: var(--left-pane-background-color);
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32,
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 #root-widget {
|
||||
body.background-effects.theme-supports-background-effects,
|
||||
body.background-effects.theme-supports-background-effects #root-widget {
|
||||
background: var(--window-background-color-bgfx) !important;
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-horizontal #horizontal-main-container,
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-vertical #vertical-main-container {
|
||||
body.background-effects.theme-supports-background-effects.layout-horizontal #horizontal-main-container,
|
||||
body.background-effects.theme-supports-background-effects.layout-vertical #vertical-main-container {
|
||||
background-color: var(--root-background);
|
||||
}
|
||||
|
||||
/* Note split with background effects */
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 #center-pane .note-split.bgfx {
|
||||
body.background-effects.theme-supports-background-effects #center-pane .note-split.bgfx {
|
||||
--note-split-background-color: var(--center-pane-background-color-bgfx);
|
||||
}
|
||||
|
||||
@@ -726,18 +739,12 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
|
||||
transform: translateX(-25%);
|
||||
}
|
||||
|
||||
body.mobile .fancytree-expander::before,
|
||||
body.mobile .fancytree-title,
|
||||
body.mobile .fancytree-node > span {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
body.mobile #mobile-sidebar-container {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body.mobile:not(.force-fixed-tree) #mobile-sidebar-wrapper {
|
||||
body.mobile #mobile-sidebar-wrapper {
|
||||
border-top-right-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
border-inline-end: 1px solid var(--subtle-border-color);
|
||||
@@ -756,9 +763,9 @@ body.mobile .fancytree-node > span {
|
||||
|
||||
#left-pane .fancytree-custom-icon {
|
||||
margin-top: 0; /* Use this to align the icon with the tree view item's caption */
|
||||
color: var(--custom-color, var(--left-pane-icon-color));
|
||||
}
|
||||
|
||||
|
||||
#left-pane span.fancytree-active .fancytree-title {
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -1054,7 +1061,7 @@ body.layout-horizontal .tab-row-widget-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.desktop:not(.background-effects.platform-win32) #root-widget.horizontal-layout {
|
||||
body.desktop:not(.background-effects) #root-widget.horizontal-layout {
|
||||
background-color: var(--root-background) !important;
|
||||
}
|
||||
|
||||
@@ -1259,7 +1266,7 @@ body.layout-horizontal #rest-pane > .classic-toolbar-widget {
|
||||
#center-pane .note-split {
|
||||
padding-top: 2px;
|
||||
background-color: var(--note-split-background-color, var(--main-background-color));
|
||||
transition: border-color 250ms ease-in;
|
||||
transition: border-color 150ms ease-out;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
@@ -1309,7 +1316,7 @@ body.mobile .note-title {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
body.desktop .title-row {
|
||||
/* Aligns the "Create new split" button with the note menu button (the three dots button) */
|
||||
padding-inline-end: 3px;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"widget-render-error": {
|
||||
"title": "فشل عرض عنصر واجهة مستخدم React مخصص"
|
||||
},
|
||||
"widget-missing-parent": "لا تحتوي الأداة المخصصة على خاصية إلزامية '{{property}}'.\n\nإذا كان من المفترض تشغيل هذا البرنامج النصي بدون عنصر واجهة مستخدم، فاستخدم '#run=frontendStartup' بدلاً من ذلك."
|
||||
"widget-missing-parent": "لا تحتوي الأداة المخصصة على خاصية إلزامية '{{property}}'.\n\nإذا كان من المفترض تشغيل هذا البرنامج النصي بدون عنصر واجهة مستخدم، فاستخدم '#run=frontendStartup' بدلاً من ذلك.",
|
||||
"open-script-note": "فتح ملاحظة برمجية",
|
||||
"scripting-error": "خطأ في النص البرمجي المخصص: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "أضافة رابط",
|
||||
@@ -37,7 +39,9 @@
|
||||
"search_note": "البحث عن الملاحظة بالاسم",
|
||||
"link_title": "عنوان الرابط",
|
||||
"button_add_link": "اضافة رابط",
|
||||
"help_on_links": "مساعدة حول الارتباطات التشعبية"
|
||||
"help_on_links": "مساعدة حول الارتباطات التشعبية",
|
||||
"link_title_mirrors": "عنوان الرابط يعكس العنوان الحالي للملاحظة",
|
||||
"link_title_arbitrary": "يمكن تغيير عنوان الرابط حسب الرغبة"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "تعديل بادئة الفرع",
|
||||
|
||||
@@ -1782,8 +1782,8 @@
|
||||
"desktop-application": "桌面应用程序",
|
||||
"native-title-bar": "原生标题栏",
|
||||
"native-title-bar-description": "对于 Windows 和 macOS,关闭原生标题栏可使应用程序看起来更紧凑。在 Linux 上,保留原生标题栏可以更好地与系统集成。",
|
||||
"background-effects": "启用背景效果(仅适用于 Windows 11)",
|
||||
"background-effects-description": "Mica 效果为应用窗口添加模糊且时尚的背景,营造出深度感和现代外观。「原生标题栏」必須被禁用。",
|
||||
"background-effects": "启用背景效果",
|
||||
"background-effects-description": "为应用窗口添加模糊且时尚的背景,营造出深度感和现代外观。「原生标题栏」必須被禁用。",
|
||||
"restart-app-button": "重启应用程序以查看更改",
|
||||
"zoom-factor": "缩放系数"
|
||||
},
|
||||
@@ -1802,7 +1802,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "创建一个新的子笔记并将其添加到地图中",
|
||||
"create-child-note-instruction": "单击地图以在该位置创建新笔记,或按 Escape 以取消。",
|
||||
"unable-to-load-map": "无法加载地图。"
|
||||
"unable-to-load-map": "无法加载地图。",
|
||||
"create-child-note-text": "添加标记"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "打开位置",
|
||||
@@ -2117,7 +2118,7 @@
|
||||
},
|
||||
"call_to_action": {
|
||||
"background_effects_title": "背景效果现已推出稳定版本",
|
||||
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
||||
"background_effects_message": "在 Windows 和 macOS 设备上,背景效果现在已稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。",
|
||||
"background_effects_button": "启用背景效果",
|
||||
"next_theme_title": "试用新 Trilium 主题",
|
||||
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
|
||||
@@ -2253,5 +2254,12 @@
|
||||
"pages_alt": "第{{pageNumber}}页",
|
||||
"pages_loading": "加载中...",
|
||||
"layers_other": "{{count}} 层"
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "在 {{platform}} 上可用"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_other": "{{count}} 选项卡",
|
||||
"more_options": "更多选项"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1751,8 +1751,8 @@
|
||||
"desktop-application": "Desktop Anwendung",
|
||||
"native-title-bar": "Native Anwendungsleiste",
|
||||
"native-title-bar-description": "In Windows und macOS, sorgt das Deaktivieren der nativen Anwendungsleiste für ein kompakteres Aussehen. Unter Linux, sorgt das Aktivieren der nativen Anwendungsleiste für eine bessere Integration mit anderen Teilen des Systems.",
|
||||
"background-effects": "Hintergrundeffekte aktivieren (nur Windows 11)",
|
||||
"background-effects-description": "Der Mica Effekt fügt einen unscharfen, stylischen Hintergrund in Anwendungsfenstern ein. Dieser erzeugt Tiefe und ein modernes Auftreten. \"Native Titelleiste\" muss deaktiviert sein.",
|
||||
"background-effects": "Hintergrundeffekte aktivieren",
|
||||
"background-effects-description": "Fügt einen unscharfen, stylischen Hintergrund in das Anwendungsfenstern ein. Dies erzeugt Tiefe und ein modernes Auftreten. \"Native Titelleiste\" muss deaktiviert sein.",
|
||||
"restart-app-button": "Anwendung neustarten um Änderungen anzuwenden",
|
||||
"zoom-factor": "Zoomfaktor"
|
||||
},
|
||||
@@ -1771,7 +1771,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Neue Unternotiz anlegen und zur Karte hinzufügen",
|
||||
"create-child-note-instruction": "Auf die Karte klicken, um eine neue Notiz an der Stelle zu erstellen oder Escape drücken um abzubrechen.",
|
||||
"unable-to-load-map": "Karte konnte nicht geladen werden."
|
||||
"unable-to-load-map": "Karte konnte nicht geladen werden.",
|
||||
"create-child-note-text": "Marker hinzufügen"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Ort öffnen",
|
||||
@@ -2133,7 +2134,7 @@
|
||||
"next_theme_message": "Es wird aktuell das alte Design verwendet. Möchten Sie das neue Design ausprobieren?",
|
||||
"next_theme_button": "Teste das neue Design",
|
||||
"background_effects_title": "Hintergrundeffekte sind jetzt zuverlässig nutzbar",
|
||||
"background_effects_message": "Auf Windows-Geräten sind die Hintergrundeffekte nun vollständig stabil. Die Hintergrundeffekte verleihen der Benutzeroberfläche einen Farbakzent, indem der Hintergrund dahinter weichgezeichnet wird. Diese Technik wird auch in anderen Anwendungen wie dem Windows-Explorer eingesetzt.",
|
||||
"background_effects_message": "Auf Windows- und macOS-Geräten sind die Hintergrundeffekte nun stabil. Die Hintergrundeffekte verleihen der Benutzeroberfläche einen Farbakzent, indem der Hintergrund dahinter weichgezeichnet wird.",
|
||||
"background_effects_button": "Aktiviere Hintergrundeffekte",
|
||||
"dismiss": "Ablehnen",
|
||||
"new_layout_title": "Neues Layout",
|
||||
@@ -2267,5 +2268,13 @@
|
||||
"pages_other": "{{count}} Seiten",
|
||||
"pages_alt": "Seite {{pageNumber}}",
|
||||
"pages_loading": "Lädt..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Verfügbar auf {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} Tab",
|
||||
"title_other": "{{count}} Tabs",
|
||||
"more_options": "Weitere Optionen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +662,8 @@
|
||||
"show-cheatsheet": "Show Cheatsheet",
|
||||
"toggle-zen-mode": "Zen Mode",
|
||||
"new-version-available": "New Update Available",
|
||||
"download-update": "Get Version {{latestVersion}}"
|
||||
"download-update": "Get Version {{latestVersion}}",
|
||||
"search_notes": "Search notes"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "Exit Zen Mode"
|
||||
@@ -745,7 +746,7 @@
|
||||
"button_title": "Export diagram as SVG"
|
||||
},
|
||||
"relation_map_buttons": {
|
||||
"create_child_note_title": "Create new child note and add it into this relation map",
|
||||
"create_child_note_title": "Create child note and add it to map",
|
||||
"reset_pan_zoom_title": "Reset pan & zoom to initial coordinates and magnification",
|
||||
"zoom_in_title": "Zoom In",
|
||||
"zoom_out_title": "Zoom Out"
|
||||
@@ -760,7 +761,9 @@
|
||||
"delete_this_note": "Delete this note",
|
||||
"note_revisions": "Note revisions",
|
||||
"error_cannot_get_branch_id": "Cannot get branchId for notePath '{{notePath}}'",
|
||||
"error_unrecognized_command": "Unrecognized command {{command}}"
|
||||
"error_unrecognized_command": "Unrecognized command {{command}}",
|
||||
"backlinks": "Backlinks",
|
||||
"content_language_switcher": "Content language: {{language}}"
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Change note icon",
|
||||
@@ -905,6 +908,7 @@
|
||||
"debug": "debug",
|
||||
"debug_description": "Debug will print extra debugging information into the console to aid in debugging complex queries",
|
||||
"action": "action",
|
||||
"option": "option",
|
||||
"search_button": "Search",
|
||||
"search_execute": "Search & Execute actions",
|
||||
"save_to_note": "Save to note",
|
||||
@@ -1958,8 +1962,8 @@
|
||||
"desktop-application": "Desktop Application",
|
||||
"native-title-bar": "Native title bar",
|
||||
"native-title-bar-description": "For Windows and macOS, keeping the native title bar off makes the application look more compact. On Linux, keeping the native title bar on integrates better with the rest of the system.",
|
||||
"background-effects": "Enable background effects (Windows 11 only)",
|
||||
"background-effects-description": "The Mica effect adds a blurred, stylish background to app windows, creating depth and a modern look. \"Native title bar\" must be disabled.",
|
||||
"background-effects": "Enable background effects",
|
||||
"background-effects-description": "Adds a blurred, stylish background to app windows, creating depth and a modern look. \"Native title bar\" must be disabled.",
|
||||
"restart-app-button": "Restart the application to view the changes",
|
||||
"zoom-factor": "Zoom factor"
|
||||
},
|
||||
@@ -1977,6 +1981,7 @@
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Create a new child note and add it to the map",
|
||||
"create-child-note-text": "Add marker",
|
||||
"create-child-note-instruction": "Click on the map to create a new note at that location or press Escape to dismiss.",
|
||||
"unable-to-load-map": "Unable to load map."
|
||||
},
|
||||
@@ -2152,7 +2157,7 @@
|
||||
"next_theme_message": "You are currently using the legacy theme, would you like to try the new theme?",
|
||||
"next_theme_button": "Try the new theme",
|
||||
"background_effects_title": "Background effects are now stable",
|
||||
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
||||
"background_effects_message": "On Windows and macOS devices, background effects are now stable. The background effects adds a touch of color to the user interface by blurring the background behind it.",
|
||||
"background_effects_button": "Enable background effects",
|
||||
"new_layout_title": "New layout",
|
||||
"new_layout_message": "We’ve introduced a modernized layout for Trilium. The ribbon has been removed and seamlessly integrated into the main interface, with a new status bar and expandable sections (such as promoted attributes) taking over key functions.\n\nThe new layout is enabled by default, and can be temporarily disabled via Options → Appearance.",
|
||||
@@ -2267,5 +2272,13 @@
|
||||
"pages_other": "{{count}} pages",
|
||||
"pages_alt": "Page {{pageNumber}}",
|
||||
"pages_loading": "Loading..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Available on {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} tab",
|
||||
"title_other": "{{count}} tabs",
|
||||
"more_options": "More options"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1614,7 +1614,7 @@
|
||||
},
|
||||
"bookmark_switch": {
|
||||
"bookmark": "Marcador",
|
||||
"bookmark_this_note": "Añadir esta nota a marcadores en el panel lateral izquierdo",
|
||||
"bookmark_this_note": "Agregar esta nota a marcadores en el panel lateral izquierdo",
|
||||
"remove_bookmark": "Eliminar marcador"
|
||||
},
|
||||
"editability_select": {
|
||||
@@ -1944,8 +1944,8 @@
|
||||
"desktop-application": "Aplicación de escritorio",
|
||||
"native-title-bar": "Barra de título nativa",
|
||||
"native-title-bar-description": "Para Windows y macOS, quitar la barra de título nativa hace que la aplicación se vea más compacta. En Linux, mantener la barra de título nativa hace que se integre mejor con el resto del sistema.",
|
||||
"background-effects": "Habilitar efectos de fondo (sólo en Windows 11)",
|
||||
"background-effects-description": "El efecto Mica agrega un fondo borroso y elegante a las ventanas de la aplicación, creando profundidad y un aspecto moderno. \"Título nativo de la barra\" debe deshabilitarse.",
|
||||
"background-effects": "Habilitar efectos de fondo",
|
||||
"background-effects-description": "Agrega un fondo borroso y elegante a las ventanas de la aplicación, creando profundidad y un aspecto moderno. \"Título nativo de la barra\" debe deshabilitarse.",
|
||||
"restart-app-button": "Reiniciar la aplicación para ver los cambios",
|
||||
"zoom-factor": "Factor de zoom"
|
||||
},
|
||||
@@ -1964,7 +1964,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crear una nueva subnota y agregarla al mapa",
|
||||
"create-child-note-instruction": "Dé clic en el mapa para crear una nueva nota en esa ubicación o presione Escape para cancelar.",
|
||||
"unable-to-load-map": "No se puede cargar el mapa."
|
||||
"unable-to-load-map": "No se puede cargar el mapa.",
|
||||
"create-child-note-text": "Agregar marcador"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Abrir ubicación",
|
||||
@@ -2130,7 +2131,7 @@
|
||||
"next_theme_message": "Estás usando actualmente el tema heredado. ¿Te gustaría probar el nuevo tema?",
|
||||
"next_theme_button": "Prueba el nuevo tema",
|
||||
"background_effects_title": "Los efectos de fondo son ahora estables",
|
||||
"background_effects_message": "En los dispositivos Windows, los efectos de fondo ya son totalmente estables. Los efectos de fondo añaden un toque de color a la interfaz de usuario difuminando el fondo que hay detrás. Esta técnica también se utiliza en otras aplicaciones como el Explorador de Windows.",
|
||||
"background_effects_message": "En los dispositivos Windows y macOS, los efectos de fondo ya son estables. Los efectos de fondo añaden un toque de color a la interfaz de usuario difuminando el fondo que hay detrás.",
|
||||
"background_effects_button": "Activar efectos de fondo",
|
||||
"dismiss": "Desestimar",
|
||||
"new_layout_title": "Nuevo diseño",
|
||||
@@ -2281,5 +2282,14 @@
|
||||
"empty_button": "Ocultar el panel",
|
||||
"toggle": "Alternar panel derecho",
|
||||
"custom_widget_go_to_source": "Ir al código fuente"
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Disponible en {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} pestaña",
|
||||
"title_many": "{{count}} pestañas",
|
||||
"title_other": "{{count}} pestañas",
|
||||
"more_options": "Más opciones"
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/client/src/translations/ga/translation.json
Normal file
5
apps/client/src/translations/ga/translation.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"global_menu": {
|
||||
"about": "Maidir le Trilium Notes"
|
||||
}
|
||||
}
|
||||
@@ -1757,8 +1757,8 @@
|
||||
"desktop-application": "デスクトップアプリケーション",
|
||||
"native-title-bar": "ネイティブタイトルバー",
|
||||
"native-title-bar-description": "WindowsとmacOSでは、ネイティブタイトルバーをオフにしておくと、アプリケーションがよりコンパクトに見えます。Linuxでは、ネイティブタイトルバーを表示したままの方が、他のシステムとの統一性が高まります。",
|
||||
"background-effects": "背景効果を有効化(Windows 11のみ)",
|
||||
"background-effects-description": "Mica効果は、アプリのウィンドウにぼかされたスタイリッシュな背景を追加し、奥行きとモダンな外観を演出します。「ネイティブタイトルバー」を無効にする必要があります。",
|
||||
"background-effects": "背景効果を有効化",
|
||||
"background-effects-description": "アプリウィンドウにぼかしの効いたスタイリッシュな背景を追加し、奥行きとモダンな外観を演出します。「ネイティブタイトルバー」を無効にする必要があります。",
|
||||
"restart-app-button": "アプリケーションを再起動して変更を反映",
|
||||
"zoom-factor": "ズーム倍率"
|
||||
},
|
||||
@@ -2008,7 +2008,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "新しい子ノートを作成し、マップに追加する",
|
||||
"create-child-note-instruction": "地図をクリックしてその場所に新しいノートを作成するか、Esc キーを押して閉じます。",
|
||||
"unable-to-load-map": "マップを読み込めません。"
|
||||
"unable-to-load-map": "マップを読み込めません。",
|
||||
"create-child-note-text": "マーカーを追加"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "現在位置を表示",
|
||||
@@ -2044,7 +2045,7 @@
|
||||
"next_theme_message": "現在、レガシーテーマを使用しています。新しいテーマを試してみませんか?",
|
||||
"next_theme_button": "新しいテーマを試す",
|
||||
"background_effects_title": "背景効果が安定しました",
|
||||
"background_effects_message": "Windowsデバイスでは、背景効果が完全に安定しました。背景効果は、背景をぼかすことでユーザーインターフェースに彩りを添えます。この技術は、Windowsエクスプローラーなどの他のアプリケーションでも使用されています。",
|
||||
"background_effects_message": "WindowsおよびmacOSデバイスで、背景効果が安定しました。背景効果は、背景をぼかすことでユーザーインターフェースに彩りを添えます。",
|
||||
"background_effects_button": "背景効果を有効にする",
|
||||
"dismiss": "却下",
|
||||
"new_layout_title": "新しいレイアウト",
|
||||
@@ -2253,5 +2254,12 @@
|
||||
"pages_other": "{{count}} ページ",
|
||||
"pages_alt": "ページ {{pageNumber}}",
|
||||
"pages_loading": "読み込み中..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "{{platform}} で利用可能"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_other": "{{count}} タブ",
|
||||
"more_options": "その他のオプション"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1761,8 +1761,8 @@
|
||||
"show-recent-notes": "Afișează notițele recente"
|
||||
},
|
||||
"electron_integration": {
|
||||
"background-effects": "Activează efectele de fundal (doar pentru Windows 11)",
|
||||
"background-effects-description": "Efectul Mica adaugă un fundal estompat și elegant ferestrelor aplicațiilor, creând profunzime și un aspect modern. Opțiunea „Bară de titlu nativă” trebuie să fie dezactivată.",
|
||||
"background-effects": "Activează efectele de fundal",
|
||||
"background-effects-description": "Adaugă un fundal estompat și elegant ferestrelor aplicațiilor, creând profunzime și un aspect modern. Opțiunea „Bară de titlu nativă” trebuie să fie dezactivată.",
|
||||
"desktop-application": "Aplicația desktop",
|
||||
"native-title-bar": "Bară de titlu nativă",
|
||||
"native-title-bar-description": "Pentru Windows și macOS, dezactivarea bării de titlu native face aplicația să pară mai compactă. Pe Linux, păstrarea bării integrează mai bine aplicația cu restul sistemului de operare.",
|
||||
@@ -1781,7 +1781,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crează o notiță nouă și adaug-o pe hartă",
|
||||
"unable-to-load-map": "Nu s-a putut încărca harta.",
|
||||
"create-child-note-instruction": "Click pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a anula."
|
||||
"create-child-note-instruction": "Click pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a anula.",
|
||||
"create-child-note-text": "Adaugă marcaj"
|
||||
},
|
||||
"duration": {
|
||||
"days": "zile",
|
||||
@@ -2127,7 +2128,7 @@
|
||||
},
|
||||
"call_to_action": {
|
||||
"background_effects_title": "Efectele de fundal sunt acum stabile",
|
||||
"background_effects_message": "Pe dispozitive cu Windows, efectele de fundal sunt complet stabile. Acestea adaugă un strop de culoare interfeței grafice prin estomparea fundalului din spatele ferestrei. Această tehnică este folosită și în alte aplicații precum Windows Explorer.",
|
||||
"background_effects_message": "Pe dispozitive cu Windows și macOS, efectele de fundal sunt stabile. Acestea adaugă un strop de culoare interfeței grafice prin estomparea fundalului din spatele ferestrei.",
|
||||
"background_effects_button": "Activează efectele de fundal",
|
||||
"next_theme_title": "Încercați noua temă Trilium",
|
||||
"next_theme_message": "Utilizați tema clasică, doriți să încercați noua temă?",
|
||||
@@ -2281,5 +2282,14 @@
|
||||
"pages_other": "{{count}} de pagini",
|
||||
"pages_alt": "Pagina {{pageNumber}}",
|
||||
"pages_loading": "Încărcare..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Disponibil pe {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} tab",
|
||||
"title_few": "{{count}} taburi",
|
||||
"title_other": "{{count}} de taburi",
|
||||
"more_options": "Mai multe opțiuni"
|
||||
}
|
||||
}
|
||||
|
||||
30
apps/client/src/types.d.ts
vendored
30
apps/client/src/types.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
import { IconRegistry, Locale } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
@@ -15,9 +15,10 @@ interface ElectronProcess {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface CustomGlobals extends BootstrapDefinition {
|
||||
interface CustomGlobals {
|
||||
isDesktop: typeof utils.isDesktop;
|
||||
isMobile: typeof utils.isMobile;
|
||||
device: "mobile" | "desktop" | "print";
|
||||
getComponentByEl: typeof appContext.getComponentByEl;
|
||||
getHeaders: typeof server.getHeaders;
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
@@ -30,7 +31,32 @@ interface CustomGlobals extends BootstrapDefinition {
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
csrfToken: string;
|
||||
baseApiUrl: string;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
instanceName: string;
|
||||
appCssNoteIds: string[];
|
||||
triliumVersion: string;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
platform?: typeof process.platform;
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
currentLocale: Locale;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
83
apps/client/src/widgets/Backlinks.css
Normal file
83
apps/client/src/widgets/Backlinks.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.tn-backlinks-widget .backlinks-items {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: static;
|
||||
width: unset;
|
||||
|
||||
> li {
|
||||
--border-radius: 8px;
|
||||
|
||||
max-width: 600px;
|
||||
padding: 10px 20px;
|
||||
background: var(--card-background-color);
|
||||
|
||||
& + li {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
/* Card header */
|
||||
& > span:first-child {
|
||||
display: block;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
/* Note path */
|
||||
> small {
|
||||
flex: 100%;
|
||||
order: -1;
|
||||
font-size: .65rem;
|
||||
|
||||
.note-path {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Note icon */
|
||||
> .tn-icon {
|
||||
color: var(--menu-item-icon-color);
|
||||
}
|
||||
|
||||
/* Note title */
|
||||
> a {
|
||||
margin-inline-start: 4px;
|
||||
color: currentColor;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Card content - excerpt */
|
||||
.backlink-excerpt {
|
||||
all: unset; /* TODO: Remove after disposing the old style from FloatingButtons.css */
|
||||
display: block;
|
||||
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
background: var(--quick-search-result-content-background);
|
||||
padding: 8px;
|
||||
font-size: .75rem;
|
||||
|
||||
a {
|
||||
background: transparent;
|
||||
color: var(--quick-search-result-highlight-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./Backlinks.css";
|
||||
|
||||
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
|
||||
import { VNode } from "preact";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
@@ -54,21 +56,12 @@ export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
OpenTriliumApiDocsButton,
|
||||
SaveToNoteButton,
|
||||
RelationMapButtons,
|
||||
GeoMapButtons,
|
||||
CopyImageReferenceButton,
|
||||
ExportImageButtons,
|
||||
InAppHelpButton,
|
||||
Backlinks
|
||||
];
|
||||
|
||||
export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
RefreshBackendLogButton,
|
||||
EditButton,
|
||||
RelationMapButtons,
|
||||
ExportImageButtons,
|
||||
Backlinks
|
||||
];
|
||||
|
||||
/**
|
||||
* Floating buttons that should be hidden in popup editor (Quick edit).
|
||||
*/
|
||||
@@ -98,10 +91,10 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
|
||||
/>;
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||
function ToggleReadOnlyButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap" || isSavedSqlite)
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
@@ -243,17 +236,6 @@ function RelationMapButtons({ note, isDefaultViewMode, triggerEvent }: FloatingB
|
||||
);
|
||||
}
|
||||
|
||||
function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
|
||||
const isEnabled = viewType === "geoMap" && !isReadOnly;
|
||||
return isEnabled && (
|
||||
<FloatingButton
|
||||
icon="bx bx-plus-circle"
|
||||
text={t("geo-map.create-child-note-title")}
|
||||
onClick={() => triggerEvent("geoMapCreateChildNote")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
||||
const isEnabled = (
|
||||
@@ -305,7 +287,7 @@ function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingB
|
||||
|
||||
function InAppHelpButton({ note }: FloatingButtonContext) {
|
||||
const helpUrl = getHelpUrlForNote(note);
|
||||
const isEnabled = !!helpUrl;
|
||||
const isEnabled = note.type !== "book" && !!helpUrl;
|
||||
|
||||
return isEnabled && (
|
||||
<FloatingButton
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
contain: none;
|
||||
|
||||
&.fixed-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.fixed-note-tree-container {
|
||||
height: 60%;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
overflow: auto;
|
||||
|
||||
.tree-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.prefers-centered-content .note-detail {
|
||||
@@ -12,4 +36,4 @@ body.prefers-centered-content .note-detail {
|
||||
|
||||
.note-detail > * {
|
||||
contain: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./NoteDetail.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -12,8 +13,9 @@ import { t } from "../services/i18n";
|
||||
import protected_session_holder from "../services/protected_session_holder";
|
||||
import toast from "../services/toast.js";
|
||||
import { dynamicRequire, isElectron, isMobile } from "../services/utils";
|
||||
import NoteTreeWidget from "./note_tree";
|
||||
import { ExtendedNoteType, TYPE_MAPPINGS, TypeWidget } from "./note_types";
|
||||
import { useNoteContext, useTriliumEvent } from "./react/hooks";
|
||||
import { useLegacyWidget, useNoteContext, useTriliumEvent } from "./react/hooks";
|
||||
import { NoteListWithLinks } from "./react/NoteList";
|
||||
import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
|
||||
@@ -36,6 +38,7 @@ export default function NoteDetail() {
|
||||
const [ noteTypesToRender, setNoteTypesToRender ] = useState<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
|
||||
const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
|
||||
const widgetRequestId = useRef(0);
|
||||
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
@@ -119,13 +122,6 @@ export default function NoteDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -215,8 +211,13 @@ export default function NoteDetail() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`component note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||
class={clsx("component note-detail", {
|
||||
"full-height": isFullHeight,
|
||||
"fixed-tree": hasFixedTree
|
||||
})}
|
||||
>
|
||||
{hasFixedTree && <FixedTree noteContext={noteContext} />}
|
||||
|
||||
{Object.entries(noteTypesToRender).map(([ itemType, Element ]) => {
|
||||
return <NoteDetailWrapper
|
||||
Element={Element}
|
||||
@@ -231,6 +232,11 @@ export default function NoteDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
function FixedTree({ noteContext }: { noteContext: NoteContext }) {
|
||||
const [ treeEl ] = useLegacyWidget(() => new NoteTreeWidget(), { noteContext });
|
||||
return <div class="fixed-note-tree-container">{treeEl}</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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
||||
import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../components/note_context";
|
||||
import FAttribute from "../entities/fattribute";
|
||||
import FNote from "../entities/fnote";
|
||||
import { Attribute } from "../services/attribute_parser";
|
||||
@@ -40,8 +41,8 @@ type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | J
|
||||
type OnChangeListener = (e: OnChangeEventData) => Promise<void>;
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
const { note, componentId } = useNoteContext();
|
||||
const [ cells, setCells ] = usePromotedAttributeData(note, componentId);
|
||||
const { note, componentId, noteContext } = useNoteContext();
|
||||
const [ cells, setCells ] = usePromotedAttributeData(note, componentId, noteContext);
|
||||
return <PromotedAttributesContent note={note} componentId={componentId} cells={cells} setCells={setCells} />;
|
||||
}
|
||||
|
||||
@@ -74,12 +75,12 @@ export function PromotedAttributesContent({ note, componentId, cells, setCells }
|
||||
*
|
||||
* The cells are returned as a state since they can also be altered internally if needed, for example to add a new empty cell.
|
||||
*/
|
||||
export function usePromotedAttributeData(note: FNote | null | undefined, componentId: string): [ Cell[] | undefined, Dispatch<StateUpdater<Cell[] | undefined>> ] {
|
||||
export function usePromotedAttributeData(note: FNote | null | undefined, componentId: string, noteContext: NoteContext | undefined): [ Cell[] | undefined, Dispatch<StateUpdater<Cell[] | undefined>> ] {
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
const [ cells, setCells ] = useState<Cell[]>();
|
||||
|
||||
function refresh() {
|
||||
if (!note || viewType === "table") {
|
||||
if (!note || viewType === "table" || noteContext?.viewScope?.viewMode !== "default") {
|
||||
setCells([]);
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +125,7 @@ export function usePromotedAttributeData(note: FNote | null | undefined, compone
|
||||
setCells(cells);
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note, viewType ]);
|
||||
useEffect(refresh, [ note, viewType, noteContext ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
|
||||
refresh();
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import "./UserAttributesList.css";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -31,7 +29,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -48,13 +46,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation";
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -63,7 +61,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
const value = attr.value;
|
||||
let value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -104,7 +102,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -29,7 +29,6 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
|
||||
const isVerticalLayout = !isHorizontalLayout;
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus();
|
||||
const isMobileLocal = isMobile();
|
||||
const logoRef = useRef<SVGSVGElement>(null);
|
||||
useStaticTooltip(logoRef);
|
||||
|
||||
@@ -44,9 +43,12 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
|
||||
</div>}
|
||||
</>}
|
||||
noDropdownListStyle
|
||||
onShown={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.add("show", "global-menu-cover") : undefined}
|
||||
onHidden={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.remove("show", "global-menu-cover") : undefined}
|
||||
mobileBackdrop
|
||||
>
|
||||
{isMobile() && <>
|
||||
<MenuItem command="searchNotes" icon="bx bx-search" text={t("global_menu.search_notes")} />
|
||||
<FormDropdownDivider />
|
||||
</>}
|
||||
|
||||
<MenuItem command="openNewWindow" icon="bx bx-window-open" text={t("global_menu.open_new_window")} />
|
||||
<MenuItem command="showShareSubtree" icon="bx bx-share-alt" text={t("global_menu.show_shared_notes_subtree")} />
|
||||
@@ -107,8 +109,7 @@ function BrowserOnlyOptions() {
|
||||
|
||||
function DevelopmentOptions({ dropStart }: { dropStart: boolean }) {
|
||||
return <>
|
||||
<FormDropdownDivider />
|
||||
<FormListItem disabled>Development Options</FormListItem>
|
||||
<FormListHeader text="Development Options" />
|
||||
<FormDropdownSubmenu icon="bx bx-test-tube" title="Experimental features" dropStart={dropStart}>
|
||||
{experimentalFeatures.map((feature) => (
|
||||
<ExperimentalFeatureToggle key={feature.id} experimentalFeature={feature as ExperimentalFeature} />
|
||||
@@ -249,7 +250,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -267,7 +268,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
if (!checkForUpdates) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.note-list-widget {
|
||||
min-height: 0;
|
||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||
|
||||
|
||||
overflow: auto;
|
||||
contain: none !important;
|
||||
}
|
||||
@@ -11,10 +11,6 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.note-list-widget .note-list {
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
.note-list-widget.full-height .note-list-widget-content {
|
||||
height: 100%;
|
||||
@@ -25,8 +21,12 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
}
|
||||
|
||||
/* #region Pagination */
|
||||
.note-list-pager span.current-page {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
.note-list-pager {
|
||||
font-size: 1rem;
|
||||
|
||||
span.current-page {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
/* #endregion */
|
||||
/* #endregion */
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
position: relative;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--card-font-size: 0.9em;
|
||||
--card-line-height: 1.2;
|
||||
@@ -19,8 +20,10 @@ body.mobile .board-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
padding-inline: 12px;
|
||||
padding-block: 4px;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.board-view-container .board-column {
|
||||
@@ -352,4 +355,4 @@ body.mobile .board-view-container .board-column {
|
||||
font-size: 0.9em;
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import "./index.css";
|
||||
import { ColumnMap, getBoardData } from "./data";
|
||||
|
||||
import { createContext, TargetedKeyboardEvent } from "preact";
|
||||
import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import toast from "../../../services/toast";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Api from "./api";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import { createContext, TargetedKeyboardEvent } from "preact";
|
||||
import { onWheelHorizontalScroll } from "../../widget_utils";
|
||||
import Column from "./column";
|
||||
import BoardApi from "./api";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete";
|
||||
import toast from "../../../services/toast";
|
||||
import { onWheelHorizontalScroll } from "../../widget_utils";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import Api from "./api";
|
||||
import BoardApi from "./api";
|
||||
import Column from "./column";
|
||||
import { ColumnMap, getBoardData } from "./data";
|
||||
|
||||
export interface BoardViewData {
|
||||
columns?: BoardColumnData[];
|
||||
@@ -145,7 +148,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
|
||||
const insertBefore = mouseX < columnMiddle;
|
||||
|
||||
// Calculate the target position
|
||||
let targetIndex = insertBefore ? index : index + 1;
|
||||
const targetIndex = insertBefore ? index : index + 1;
|
||||
|
||||
setColumnDropPosition(targetIndex);
|
||||
}, [draggedColumn]);
|
||||
@@ -159,15 +162,14 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
|
||||
}, [draggedColumn, columnDropPosition, handleColumnDrop]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board-view"
|
||||
onWheel={onWheelHorizontalScroll}
|
||||
>
|
||||
<div className="board-view">
|
||||
<CollectionProperties note={parentNote} />
|
||||
<BoardViewContext.Provider value={boardViewContext}>
|
||||
{byColumn && columns && <div
|
||||
className="board-view-container"
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDrop={handleContainerDrop}
|
||||
onWheel={onWheelHorizontalScroll}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<>
|
||||
@@ -194,7 +196,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
|
||||
</div>}
|
||||
</BoardViewContext.Provider>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMode: boolean }) {
|
||||
@@ -218,26 +220,26 @@ function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMo
|
||||
tabIndex={300}
|
||||
>
|
||||
{!isCreatingNewColumn
|
||||
? <>
|
||||
<Icon icon="bx bx-plus" />{" "}
|
||||
{t("board_view.add-column")}
|
||||
</>
|
||||
: (
|
||||
<TitleEditor
|
||||
placeholder={t("board_view.add-column-placeholder")}
|
||||
save={async (columnName) => {
|
||||
const created = await api.addNewColumn(columnName);
|
||||
if (!created) {
|
||||
toast.showMessage(t("board_view.column-already-exists"), undefined, "bx bx-duplicate");
|
||||
}
|
||||
}}
|
||||
dismiss={() => setIsCreatingNewColumn(false)}
|
||||
isNewItem
|
||||
mode={isInRelationMode ? "relation" : "normal"}
|
||||
/>
|
||||
)}
|
||||
? <>
|
||||
<Icon icon="bx bx-plus" />{" "}
|
||||
{t("board_view.add-column")}
|
||||
</>
|
||||
: (
|
||||
<TitleEditor
|
||||
placeholder={t("board_view.add-column-placeholder")}
|
||||
save={async (columnName) => {
|
||||
const created = await api.addNewColumn(columnName);
|
||||
if (!created) {
|
||||
toast.showMessage(t("board_view.column-already-exists"), undefined, "bx bx-duplicate");
|
||||
}
|
||||
}}
|
||||
dismiss={() => setIsCreatingNewColumn(false)}
|
||||
isNewItem
|
||||
mode={isInRelationMode ? "relation" : "normal"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, isNewItem }: {
|
||||
@@ -302,26 +304,26 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, is
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NoteAutocomplete
|
||||
inputRef={inputRef}
|
||||
noteId={currentValue ?? ""}
|
||||
opts={{
|
||||
hideAllButtons: true,
|
||||
allowCreatingNotes: true
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
dismiss();
|
||||
}
|
||||
}}
|
||||
onBlur={() => dismiss()}
|
||||
noteIdChanged={(newValue) => {
|
||||
save(newValue);
|
||||
dismiss();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NoteAutocomplete
|
||||
inputRef={inputRef}
|
||||
noteId={currentValue ?? ""}
|
||||
opts={{
|
||||
hideAllButtons: true,
|
||||
allowCreatingNotes: true
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
dismiss();
|
||||
}
|
||||
}}
|
||||
onBlur={() => dismiss()}
|
||||
noteIdChanged={(newValue) => {
|
||||
save(newValue);
|
||||
dismiss();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@
|
||||
outline: 0;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
padding: 10px;
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 991px) {
|
||||
padding: 0;
|
||||
|
||||
th {
|
||||
font-weight: normal;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-view a,
|
||||
@@ -43,6 +52,7 @@
|
||||
--fc-border-color: var(--main-border-color);
|
||||
--fc-neutral-bg-color: var(--launcher-pane-background-color);
|
||||
--fc-list-event-hover-bg-color: var(--left-pane-item-hover-background);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.calendar-container .fc-list-sticky .fc-list-day > * {
|
||||
@@ -59,33 +69,35 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* #region Header */
|
||||
.calendar-view .calendar-header {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.calendar-view .collection-properties {
|
||||
.center-container {
|
||||
justify-content: center;
|
||||
|
||||
.calendar-view .calendar-header .btn {
|
||||
min-width: unset !important;
|
||||
}
|
||||
.title {
|
||||
min-width: 150px;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-view .calendar-header > .title {
|
||||
flex-grow: 1;
|
||||
font-size: 1.3rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
>div {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
body.desktop:not(.zen):not(.experimental-feature-new-layout) .calendar-view .calendar-header {
|
||||
padding-block-start: 4px;
|
||||
padding-inline-end: 5em;
|
||||
}
|
||||
.right-container {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.search-result-widget-content .calendar-view .calendar-header {
|
||||
padding-inline-end: unset !important;
|
||||
.center-container {
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
min-width: 110px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Events */
|
||||
|
||||
@@ -93,7 +105,7 @@ body.desktop:not(.zen):not(.experimental-feature-new-layout) .calendar-view .cal
|
||||
* week, month, year views
|
||||
*/
|
||||
|
||||
.calendar-container a.fc-event {
|
||||
.calendar-container a.fc-event {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ import dialog from "../../../services/dialog";
|
||||
import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { isMobile } from "../../../services/utils";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Button, { ButtonGroup } from "../../react/Button";
|
||||
import Dropdown from "../../react/Dropdown";
|
||||
import { FormListItem } from "../../react/FormList";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
||||
@@ -41,24 +44,28 @@ const CALENDAR_VIEWS = [
|
||||
{
|
||||
type: "timeGridWeek",
|
||||
name: t("calendar.week"),
|
||||
icon: "bx bx-calendar-week",
|
||||
previousText: t("calendar.week_previous"),
|
||||
nextText: t("calendar.week_next")
|
||||
},
|
||||
{
|
||||
type: "dayGridMonth",
|
||||
name: t("calendar.month"),
|
||||
icon: "bx bx-calendar",
|
||||
previousText: t("calendar.month_previous"),
|
||||
nextText: t("calendar.month_next")
|
||||
},
|
||||
{
|
||||
type: "multiMonthYear",
|
||||
name: t("calendar.year"),
|
||||
icon: "bx bx-layer",
|
||||
previousText: t("calendar.year_previous"),
|
||||
nextText: t("calendar.year_next")
|
||||
},
|
||||
{
|
||||
type: "listMonth",
|
||||
name: t("calendar.list"),
|
||||
icon: "bx bx-list-ol",
|
||||
previousText: t("calendar.month_previous"),
|
||||
nextText: t("calendar.month_next")
|
||||
}
|
||||
@@ -140,7 +147,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
|
||||
|
||||
return (plugins &&
|
||||
<div className="calendar-view" ref={containerRef} tabIndex={100}>
|
||||
<CalendarHeader calendarRef={calendarRef} />
|
||||
<CalendarCollectionProperties note={note} calendarRef={calendarRef} />
|
||||
<Calendar
|
||||
events={eventBuilder}
|
||||
calendarRef={calendarRef}
|
||||
@@ -169,28 +176,67 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarHeader({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
||||
function CalendarCollectionProperties({ note, calendarRef }: {
|
||||
note: FNote;
|
||||
calendarRef: RefObject<FullCalendar>;
|
||||
}) {
|
||||
const { title, viewType: currentViewType } = useOnDatesSet(calendarRef);
|
||||
const currentViewData = CALENDAR_VIEWS.find(v => calendarRef.current && v.type === currentViewType);
|
||||
const isMobileLocal = isMobile();
|
||||
|
||||
return (
|
||||
<div className="calendar-header">
|
||||
<span className="title">{title}</span>
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
centerChildren={<>
|
||||
<ActionButton icon="bx bx-chevron-left" text={currentViewData?.previousText ?? ""} onClick={() => calendarRef.current?.prev()} />
|
||||
<span className="title">{title}</span>
|
||||
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} onClick={() => calendarRef.current?.next()} />
|
||||
<Button text={t("calendar.today")} onClick={() => calendarRef.current?.today()} />
|
||||
{isMobileLocal && <MobileCalendarViewSwitcher calendarRef={calendarRef} />}
|
||||
</>}
|
||||
rightChildren={<>
|
||||
{!isMobileLocal && <DesktopCalendarViewSwitcher calendarRef={calendarRef} />}
|
||||
</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopCalendarViewSwitcher({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
||||
const { viewType: currentViewType } = useOnDatesSet(calendarRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup>
|
||||
{CALENDAR_VIEWS.map(viewData => (
|
||||
<Button
|
||||
key={viewData.type}
|
||||
text={viewData.name}
|
||||
className={currentViewType === viewData.type ? "active" : ""}
|
||||
onClick={() => calendarRef.current?.changeView(viewData.type)}
|
||||
/>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
<Button text={t("calendar.today")} onClick={() => calendarRef.current?.today()} />
|
||||
<ButtonGroup>
|
||||
<ActionButton icon="bx bx-chevron-left" text={currentViewData?.previousText ?? ""} frame onClick={() => calendarRef.current?.prev()} />
|
||||
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} frame onClick={() => calendarRef.current?.next()} />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCalendarViewSwitcher({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
||||
const { viewType: currentViewType } = useOnDatesSet(calendarRef);
|
||||
const currentViewTypeData = CALENDAR_VIEWS.find(view => view.type === currentViewType);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
text={currentViewTypeData?.name}
|
||||
>
|
||||
{CALENDAR_VIEWS.map(viewData => (
|
||||
<FormListItem
|
||||
key={viewData.type}
|
||||
selected={currentViewType === viewData.type}
|
||||
icon={viewData.icon}
|
||||
onClick={() => calendarRef.current?.changeView(viewData.type)}
|
||||
>{viewData.name}</FormListItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .collection-properties {
|
||||
position: relative;
|
||||
z-index: 2000;
|
||||
}
|
||||
}
|
||||
|
||||
.geo-map-container {
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import Map from "./map";
|
||||
import "./index.css";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||
|
||||
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import Marker, { GpxTrack } from "./marker";
|
||||
import froca from "../../../services/froca";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import { createNewNote, moveMarker } from "./api";
|
||||
import openContextMenu, { openMapContextMenu } from "./context_menu";
|
||||
import toast from "../../../services/toast";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import branches from "../../../services/branches";
|
||||
import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import branches from "../../../services/branches";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSlider } from "../../react/TouchBar";
|
||||
import toast from "../../../services/toast";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import { ButtonOrActionButton } from "../../react/Button";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import TouchBar, { TouchBarButton, TouchBarSlider } from "../../react/TouchBar";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { createNewNote, moveMarker } from "./api";
|
||||
import openContextMenu, { openMapContextMenu } from "./context_menu";
|
||||
import Map from "./map";
|
||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||
import Marker, { GpxTrack } from "./marker";
|
||||
|
||||
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
|
||||
const DEFAULT_ZOOM = 2;
|
||||
@@ -50,7 +55,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]);
|
||||
useEffect(() => { froca.getNotes(noteIds).then(setNotes); }, [ noteIds ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!note) return;
|
||||
@@ -60,7 +65,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
|
||||
// Note creation.
|
||||
useTriliumEvent("geoMapCreateChildNote", () => {
|
||||
toast.showPersistent({
|
||||
toast.showPersistent({
|
||||
icon: "plus",
|
||||
id: "geo-new-note",
|
||||
title: "New note",
|
||||
@@ -130,6 +135,19 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
|
||||
return (
|
||||
<div className={`geo-view ${state === State.NewNote ? "placing-note" : ""}`}>
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
rightChildren={<>
|
||||
<ToggleReadOnlyButton note={note} />
|
||||
<ButtonOrActionButton
|
||||
icon="bx bx-plus"
|
||||
text={t("geo-map.create-child-note-text")}
|
||||
title={t("geo-map.create-child-note-title")}
|
||||
triggerCommand="geoMapCreateChildNote"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</>}
|
||||
/>
|
||||
{ coordinates !== undefined && zoom !== undefined && <Map
|
||||
apiRef={apiRef} containerRef={containerRef}
|
||||
coordinates={coordinates}
|
||||
@@ -151,6 +169,16 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note }: { note: FNote }) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
|
||||
return <ActionButton
|
||||
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
|
||||
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
|
||||
onClick={() => setReadOnly(!isReadOnly)}
|
||||
/>;
|
||||
}
|
||||
|
||||
function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) {
|
||||
const mime = useNoteProperty(note, "mime");
|
||||
const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE);
|
||||
@@ -204,7 +232,7 @@ function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean
|
||||
onDragged={editable ? onDragged : undefined}
|
||||
onClick={!editable ? onClick : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
|
||||
function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
@@ -238,7 +266,7 @@ function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
color: note.getLabelValue("color") ?? "blue"
|
||||
}
|
||||
}), [ color, iconClass ]);
|
||||
return xmlString && <GpxTrack gpxXmlString={xmlString} options={options} />
|
||||
return xmlString && <GpxTrack gpxXmlString={xmlString} options={options} />;
|
||||
}
|
||||
|
||||
function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string, archived?: boolean) {
|
||||
@@ -292,5 +320,5 @@ function GeoMapTouchBar({ state, map }: { state: State, map: L.Map | null | unde
|
||||
enabled={state === State.Normal}
|
||||
/>
|
||||
</TouchBar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import attribute_renderer from "../../../services/attribute_renderer";
|
||||
import content_renderer from "../../../services/content_renderer";
|
||||
import { t } from "../../../services/i18n";
|
||||
import link from "../../../services/link";
|
||||
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean } from "../../react/hooks";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean, useNoteProperty } from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoteLink from "../../react/NoteLink";
|
||||
import { ViewModeProps } from "../interface";
|
||||
@@ -19,11 +20,18 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
const noteType = useNoteProperty(note, "type");
|
||||
const hasCollectionProperties = [ "book", "search" ].includes(noteType ?? "");
|
||||
|
||||
return (
|
||||
<div class="note-list list-view">
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
centerChildren={<Pager {...pagination} />}
|
||||
/>
|
||||
|
||||
{ noteIds.length > 0 && <div class="note-list-wrapper">
|
||||
<Pager {...pagination} />
|
||||
{!hasCollectionProperties && <Pager {...pagination} />}
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
{pageNotes?.map(childNote => (
|
||||
@@ -45,11 +53,18 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
const noteType = useNoteProperty(note, "type");
|
||||
const hasCollectionProperties = [ "book", "search" ].includes(noteType ?? "");
|
||||
|
||||
return (
|
||||
<div class="note-list grid-view">
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
centerChildren={<Pager {...pagination} />}
|
||||
/>
|
||||
|
||||
<div class="note-list-wrapper">
|
||||
<Pager {...pagination} />
|
||||
{!hasCollectionProperties && <Pager {...pagination} />}
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
{pageNotes?.map(childNote => (
|
||||
@@ -140,7 +155,7 @@ function NoteAttributes({ note }: { note: FNote }) {
|
||||
return <span className="note-list-attributes" ref={ref} />;
|
||||
}
|
||||
|
||||
function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
|
||||
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
|
||||
note: FNote;
|
||||
trim?: boolean;
|
||||
noChildrenList?: boolean;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
.presentation-button-bar {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
|
||||
.floating-buttons-children {
|
||||
top: 0;
|
||||
}
|
||||
.presentation-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.presentation-container {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { ViewModeMedia, ViewModeProps } from "../interface";
|
||||
import "./index.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import Reveal from "reveal.js";
|
||||
import slideBaseStylesheet from "reveal.js/dist/reveal.css?raw";
|
||||
import slideCustomStylesheet from "./slidejs.css?raw";
|
||||
import { buildPresentationModel, PresentationModel, PresentationSlideBaseModel } from "./model";
|
||||
import ShadowDom from "../../react/ShadowDom";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import "./index.css";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
import { openInCurrentNoteContext } from "../../../components/note_context";
|
||||
import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { DEFAULT_THEME, loadPresentationTheme } from "./themes";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks";
|
||||
import ShadowDom from "../../react/ShadowDom";
|
||||
import { ViewModeMedia, ViewModeProps } from "../interface";
|
||||
import { buildPresentationModel, PresentationModel, PresentationSlideBaseModel } from "./model";
|
||||
import slideCustomStylesheet from "./slidejs.css?raw";
|
||||
import { DEFAULT_THEME, loadPresentationTheme } from "./themes";
|
||||
|
||||
export default function PresentationView({ note, noteIds, media, onReady, onProgressChanged }: ViewModeProps<{}>) {
|
||||
const [ presentation, setPresentation ] = useState<PresentationModel>();
|
||||
@@ -51,14 +54,17 @@ export default function PresentationView({ note, noteIds, media, onReady, onProg
|
||||
|
||||
if (media === "screen") {
|
||||
return (
|
||||
<>
|
||||
<div class="presentation-view">
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
rightChildren={<ButtonOverlay containerRef={containerRef} api={api} />}
|
||||
/>
|
||||
<ShadowDom
|
||||
className="presentation-container"
|
||||
containerRef={containerRef}
|
||||
>{content}</ShadowDom>
|
||||
<ButtonOverlay containerRef={containerRef} api={api} />
|
||||
</>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
} else if (media === "print") {
|
||||
// Printing needs a query parameter that is read by Reveal.js.
|
||||
const url = new URL(window.location.href);
|
||||
@@ -108,42 +114,34 @@ function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivE
|
||||
}, [ api ]);
|
||||
|
||||
return (
|
||||
<div className="presentation-button-bar">
|
||||
<div className="floating-buttons-children">
|
||||
<ActionButton
|
||||
className="floating-button"
|
||||
icon="bx bx-edit"
|
||||
text={t("presentation_view.edit-slide")}
|
||||
noIconActionClass
|
||||
onClick={e => {
|
||||
const currentSlide = api?.getCurrentSlide();
|
||||
const noteId = getNoteIdFromSlide(currentSlide);
|
||||
<>
|
||||
<ActionButton
|
||||
icon="bx bx-edit"
|
||||
text={t("presentation_view.edit-slide")}
|
||||
onClick={e => {
|
||||
const currentSlide = api?.getCurrentSlide();
|
||||
const noteId = getNoteIdFromSlide(currentSlide);
|
||||
|
||||
if (noteId) {
|
||||
openInCurrentNoteContext(e, noteId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
if (noteId) {
|
||||
openInCurrentNoteContext(e, noteId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
className="floating-button"
|
||||
icon="bx bx-grid-horizontal"
|
||||
text={t("presentation_view.slide-overview")}
|
||||
active={isOverviewActive}
|
||||
noIconActionClass
|
||||
onClick={() => api?.toggleOverview()}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="bx bx-grid-horizontal"
|
||||
text={t("presentation_view.slide-overview")}
|
||||
active={isOverviewActive}
|
||||
onClick={() => api?.toggleOverview()}
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
className="floating-button"
|
||||
icon="bx bx-fullscreen"
|
||||
text={t("presentation_view.start-presentation")}
|
||||
noIconActionClass
|
||||
onClick={() => containerRef.current?.requestFullscreen()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<ActionButton
|
||||
icon="bx bx-fullscreen"
|
||||
text={t("presentation_view.start-presentation")}
|
||||
onClick={() => containerRef.current?.requestFullscreen()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: Reveal.Api | undefined) => void }) {
|
||||
@@ -179,7 +177,7 @@ function Presentation({ presentation, setApi } : { presentation: PresentationMod
|
||||
api.destroy();
|
||||
setRevealApi(undefined);
|
||||
setApi(undefined);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,19 +189,19 @@ function Presentation({ presentation, setApi } : { presentation: PresentationMod
|
||||
<div className="slides">
|
||||
{presentation.slides?.map(slide => {
|
||||
if (!slide.verticalSlides) {
|
||||
return <Slide key={slide.noteId} slide={slide} />
|
||||
} else {
|
||||
return (
|
||||
<section>
|
||||
<Slide key={slide.noteId} slide={slide} />
|
||||
{slide.verticalSlides.map(slide => <Slide key={slide.noteId} slide={slide} /> )}
|
||||
</section>
|
||||
);
|
||||
return <Slide key={slide.noteId} slide={slide} />;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<Slide key={slide.noteId} slide={slide} />
|
||||
{slide.verticalSlides.map(slide => <Slide key={slide.noteId} slide={slide} /> )}
|
||||
</section>
|
||||
);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
import { JSX } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
|
||||
import froca from "../../../services/froca.js";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { JSX } from "preact";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import froca from "../../../services/froca.js";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@@ -79,7 +78,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>}
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
@@ -201,14 +200,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
|
||||
editorParams: {},
|
||||
) => HTMLElement | false) {
|
||||
return (cell, _, success, cancel, editorParams) => {
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />;
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function NoteFormatter({ cell }: FormatterOpts) {
|
||||
const noteId = cell.getValue();
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null);
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId || note?.noteId === noteId) return;
|
||||
@@ -232,5 +231,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>;
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
position: relative;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
padding: 0 5px 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tabulator-tableholder {
|
||||
height: unset !important;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { TableData } from "./rows";
|
||||
import { useLegacyWidget } from "../../react/hooks";
|
||||
import Tabulator from "./tabulator";
|
||||
import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule, Options, RowComponent} from 'tabulator-tables';
|
||||
import { useContextMenu } from "./context_menu";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Button from "../../react/Button";
|
||||
import "./index.css";
|
||||
import useRowTableEditing from "./row_editing";
|
||||
import useColTableEditing from "./col_editing";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { DataTreeModule, EditModule, FormatModule, FrozenColumnsModule, InteractionModule, MoveColumnsModule, MoveRowsModule, Options, PersistenceModule, ResizeColumnsModule, RowComponent,SortModule, Tabulator as VanillaTabulator} from 'tabulator-tables';
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import SpacedUpdate from "../../../services/spaced_update";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import { ButtonOrActionButton } from "../../react/Button";
|
||||
import { useLegacyWidget } from "../../react/hooks";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import useColTableEditing from "./col_editing";
|
||||
import { useContextMenu } from "./context_menu";
|
||||
import useData, { TableConfig } from "./data";
|
||||
import useRowTableEditing from "./row_editing";
|
||||
import { TableData } from "./rows";
|
||||
import Tabulator from "./tabulator";
|
||||
|
||||
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
|
||||
const tabulatorRef = useRef<VanillaTabulator>(null);
|
||||
@@ -36,7 +38,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
|
||||
dataTreeChildIndent: 20,
|
||||
dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
|
||||
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
|
||||
}
|
||||
};
|
||||
}, [ hasChildren ]);
|
||||
|
||||
const rowFormatter = useCallback((row: RowComponent) => {
|
||||
@@ -46,6 +48,16 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
|
||||
|
||||
return (
|
||||
<div className="table-view">
|
||||
<CollectionProperties
|
||||
note={note}
|
||||
rightChildren={note.type !== "search" &&
|
||||
<>
|
||||
<ButtonOrActionButton triggerCommand="addNewRow" icon="bx bx-plus" text={t("table_view.new-row")} />
|
||||
<ButtonOrActionButton triggerCommand="addNewTableColumn" icon="bx bx-carousel" text={t("table_view.new-column")} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{rowData !== undefined && persistenceProps && (
|
||||
<>
|
||||
<Tabulator
|
||||
@@ -54,7 +66,6 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
|
||||
columns={columnDefs ?? []}
|
||||
data={rowData}
|
||||
modules={[ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]}
|
||||
footerElement={<TableFooter note={note} />}
|
||||
events={{
|
||||
...contextMenuEvents,
|
||||
...rowEditingEvents
|
||||
@@ -67,24 +78,11 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
|
||||
rowFormatter={rowFormatter}
|
||||
{...dataTreeProps}
|
||||
/>
|
||||
<TableFooter note={note} />
|
||||
</>
|
||||
)}
|
||||
{attributeDetailWidgetEl}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ note }: { note: FNote }) {
|
||||
return (note.type !== "search" &&
|
||||
<div className="tabulator-footer">
|
||||
<div className="tabulator-footer-contents">
|
||||
<Button triggerCommand="addNewRow" icon="bx bx-plus" text={t("table_view.new-row")} />
|
||||
{" "}
|
||||
<Button triggerCommand="addNewTableColumn" icon="bx bx-carousel" text={t("table_view.new-column")} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function usePersistence(viewConfig: TableConfig | null | undefined, saveConfig: (newConfig: TableConfig) => void) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EventData } from "../../components/app_context.js";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { readCssVar } from "../../utils/css-var.js";
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import options from "../../services/options.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
import { EventData } from "../../components/app_context.js";
|
||||
import { getEnabledExperimentalFeatureIds } from "../../services/experimental_features.js";
|
||||
import options from "../../services/options.js";
|
||||
import utils, { isMobile } from "../../services/utils.js";
|
||||
import { readCssVar } from "../../utils/css-var.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import FlexContainer from "./flex_container.js";
|
||||
|
||||
/**
|
||||
* The root container is the top-most widget/container, from which the entire layout derives.
|
||||
@@ -17,14 +18,12 @@ import { getEnabledExperimentalFeatureIds } from "../../services/experimental_fe
|
||||
* - `#root-container.vertical-layout`, if the current layout is horizontal.
|
||||
*/
|
||||
export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
private originalViewportHeight: number;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super(isHorizontalLayout ? "column" : "row");
|
||||
|
||||
this.id("root-widget");
|
||||
this.css("height", "100dvh");
|
||||
this.originalViewportHeight = getViewportHeight();
|
||||
}
|
||||
|
||||
render(): JQuery<HTMLElement> {
|
||||
@@ -64,8 +63,12 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
|
||||
#onMobileResize() {
|
||||
const currentViewportHeight = getViewportHeight();
|
||||
const isKeyboardOpened = (currentViewportHeight < this.originalViewportHeight);
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// If viewport is significantly smaller, keyboard is likely open
|
||||
const isKeyboardOpened = windowHeight - viewportHeight > 150;
|
||||
|
||||
this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened);
|
||||
}
|
||||
|
||||
@@ -88,7 +91,7 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
|
||||
#setBackdropEffects() {
|
||||
const enabled = options.is("backdropEffectsEnabled");
|
||||
const enabled = options.is("backdropEffectsEnabled") && !isMobile();
|
||||
document.body.classList.toggle("backdrop-effects-disabled", !enabled);
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
// Supports background effects
|
||||
|
||||
const useBgfx = readCssVar(document.documentElement, "allow-background-effects")
|
||||
.asBoolean(false);
|
||||
.asBoolean(false);
|
||||
|
||||
document.body.classList.toggle("theme-supports-background-effects", useBgfx);
|
||||
}
|
||||
@@ -114,6 +117,3 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportHeight() {
|
||||
return window.visualViewport?.height ?? window.innerHeight;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
.scrolling-container {
|
||||
--content-margin-inline: 24px;
|
||||
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
|
||||
> .inline-title,
|
||||
> .note-detail > .note-detail-editable-text,
|
||||
> .note-list-widget:not(.full-height) {
|
||||
padding-inline: 24px;
|
||||
> .note-detail > .note-detail-editable-text > .note-detail-editable-text-editor,
|
||||
> .note-list-widget:not(.full-height) .note-list-wrapper {
|
||||
margin-inline: var(--content-margin-inline);
|
||||
}
|
||||
|
||||
> .inline-title {
|
||||
padding-inline: var(--content-margin-inline);
|
||||
}
|
||||
|
||||
> .note-detail > .note-detail-editable-text > .note-detail-editable-text-editor {
|
||||
overflow: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.note-split.type-code:not(.mime-text-x-sqlite) {
|
||||
|
||||
@@ -91,8 +91,9 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .note-detail-editable-text {
|
||||
padding: 0 1em;
|
||||
.modal.popup-editor-dialog .note-detail-editable-text-editor {
|
||||
margin: 0 28px;
|
||||
overflow: visible; /* Allow selection rectangle to go outside of the editor area */
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .note-detail-file {
|
||||
|
||||
@@ -5,13 +5,15 @@ import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import froca from "../../services/froca";
|
||||
import { t } from "../../services/i18n";
|
||||
import tree from "../../services/tree";
|
||||
import utils from "../../services/utils";
|
||||
import NoteList from "../collections/NoteList";
|
||||
import FloatingButtons from "../FloatingButtons";
|
||||
import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
|
||||
import { DESKTOP_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
|
||||
import NoteBadges from "../layout/NoteBadges";
|
||||
import NoteIcon from "../note_icon";
|
||||
import NoteTitleWidget from "../note_title";
|
||||
import NoteDetail from "../NoteDetail";
|
||||
@@ -23,8 +25,6 @@ import ReadOnlyNoteInfoBar from "../ReadOnlyNoteInfoBar";
|
||||
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
|
||||
import FormattingToolbar from "../ribbon/FormattingToolbar";
|
||||
import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar";
|
||||
import NoteBadges from "../layout/NoteBadges";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
|
||||
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function PopupEditor() {
|
||||
const [ noteContext, setNoteContext ] = useState(new NoteContext("_popup-editor"));
|
||||
const isMobile = utils.isMobile();
|
||||
const items = useMemo(() => {
|
||||
const baseItems = isMobile ? MOBILE_FLOATING_BUTTONS : DESKTOP_FLOATING_BUTTONS;
|
||||
const baseItems = isMobile ? [] : DESKTOP_FLOATING_BUTTONS;
|
||||
return baseItems.filter(item => !POPUP_HIDDEN_FLOATING_BUTTONS.includes(item));
|
||||
}, [ isMobile ]);
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import Modal from "../react/Modal.js";
|
||||
|
||||
export default function AboutDialog() {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
@@ -55,15 +54,15 @@ export default function AboutDialog() {
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td className="selectable-text">
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak} rel="noreferrer">{appInfo.buildRevision}</a>}
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
{ appInfo?.dataDirectory && <tr>
|
||||
<tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
|
||||
</td>
|
||||
</tr>}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
@@ -77,8 +76,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>;
|
||||
}
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
} else {
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import options from "../../services/options";
|
||||
import utils from "../../services/utils";
|
||||
import utils, { isMac } from "../../services/utils";
|
||||
|
||||
/**
|
||||
* A "call-to-action" is an interactive message for the user, generally to present new features.
|
||||
@@ -41,10 +41,6 @@ export interface CallToAction {
|
||||
}[];
|
||||
}
|
||||
|
||||
function isNextTheme() {
|
||||
return [ "next", "next-light", "next-dark" ].includes(options.get("theme"));
|
||||
}
|
||||
|
||||
const CALL_TO_ACTIONS: CallToAction[] = [
|
||||
{
|
||||
id: "new_layout",
|
||||
@@ -63,7 +59,7 @@ const CALL_TO_ACTIONS: CallToAction[] = [
|
||||
id: "background_effects",
|
||||
title: t("call_to_action.background_effects_title"),
|
||||
message: t("call_to_action.background_effects_message"),
|
||||
enabled: () => false,
|
||||
enabled: () => (isMac() && !options.is("backgroundEffects")),
|
||||
buttons: [
|
||||
{
|
||||
text: t("call_to_action.background_effects_button"),
|
||||
@@ -78,7 +74,7 @@ const CALL_TO_ACTIONS: CallToAction[] = [
|
||||
id: "next_theme",
|
||||
title: t("call_to_action.next_theme_title"),
|
||||
message: t("call_to_action.next_theme_message"),
|
||||
enabled: () => !isNextTheme(),
|
||||
enabled: () => ![ "next", "next-light", "next-dark" ].includes(options.get("theme")),
|
||||
buttons: [
|
||||
{
|
||||
text: t("call_to_action.next_theme_button"),
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function RecentChangesDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ancestorNoteId) return;
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { isDesktop, isMobile } from "../../services/utils";
|
||||
import TabSwitcher from "../mobile_widgets/TabSwitcher";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { onWheelHorizontalScroll } from "../widget_utils";
|
||||
import BookmarkButtons from "./BookmarkButtons";
|
||||
@@ -97,6 +98,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
|
||||
return <QuickSearchLauncherWidget />;
|
||||
case "aiChatLauncher":
|
||||
return <AiChatButton launcherNote={note} />;
|
||||
case "mobileTabSwitcher":
|
||||
return <TabSwitcher />;
|
||||
default:
|
||||
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { createContext } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
@@ -18,12 +19,12 @@ export interface LauncherNoteProps {
|
||||
launcherNote: FNote;
|
||||
}
|
||||
|
||||
export function LaunchBarActionButton(props: Omit<ActionButtonProps, "className" | "noIconActionClass" | "titlePosition">) {
|
||||
export function LaunchBarActionButton({ className, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition">) {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className="button-widget launcher-button"
|
||||
className={clsx("button-widget launcher-button", className)}
|
||||
noIconActionClass
|
||||
titlePosition={isHorizontalLayout ? "bottom" : "right"}
|
||||
{...props}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tooltip } from "bootstrap";
|
||||
import clsx from "clsx";
|
||||
import { ComponentChild } from "preact";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
|
||||
@@ -50,6 +50,9 @@ body.experimental-feature-new-layout {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body.desktop .title-actions {
|
||||
> .collapsible,
|
||||
> .note-type-switcher {
|
||||
padding-inline-start: calc(24px - var(--title-actions-padding-start));
|
||||
@@ -57,3 +60,4 @@ body.experimental-feature-new-layout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import "./NoteTitleActions.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import CollectionProperties from "../note_bars/CollectionProperties";
|
||||
import { checkFullHeight, getExtendedWidgetType } from "../NoteDetail";
|
||||
import { PromotedAttributesContent, usePromotedAttributeData } from "../PromotedAttributes";
|
||||
import SimpleBadge from "../react/Badge";
|
||||
import Collapsible, { ExternallyControlledCollapsible } from "../react/Collapsible";
|
||||
import { useNoteContext, useNoteLabel, useNoteProperty, useTriliumEvent, useTriliumOptionBool } from "../react/hooks";
|
||||
import NoteLink, { NewNoteLink } from "../react/NoteLink";
|
||||
import { NewNoteLink } from "../react/NoteLink";
|
||||
import { useEditedNotes } from "../ribbon/EditedNotesTab";
|
||||
import SearchDefinitionTab from "../ribbon/SearchDefinitionTab";
|
||||
import NoteTypeSwitcher from "./NoteTypeSwitcher";
|
||||
|
||||
export default function NoteTitleActions() {
|
||||
const { note, ntxId, componentId, noteContext } = useNoteContext();
|
||||
const isHiddenNote = note && note.noteId !== "_search" && note.noteId.startsWith("_");
|
||||
const { note, ntxId, componentId, noteContext, viewScope } = useNoteContext();
|
||||
const noteType = useNoteProperty(note, "type");
|
||||
|
||||
return (
|
||||
<div className="title-actions">
|
||||
<PromotedAttributes note={note} componentId={componentId} noteContext={noteContext} />
|
||||
{noteType === "search" && <SearchProperties note={note} ntxId={ntxId} />}
|
||||
{!isHiddenNote && note && noteType === "book" && <CollectionProperties note={note} />}
|
||||
<EditedNotes />
|
||||
<NoteTypeSwitcher />
|
||||
{viewScope?.viewMode === "default" && <NoteTypeSwitcher />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +44,7 @@ function PromotedAttributes({ note, componentId, noteContext }: {
|
||||
componentId: string,
|
||||
noteContext: NoteContext | undefined
|
||||
}) {
|
||||
const [ cells, setCells ] = usePromotedAttributeData(note, componentId);
|
||||
const [ cells, setCells ] = usePromotedAttributeData(note, componentId, noteContext);
|
||||
const [ expanded, setExpanded ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -58,101 +58,12 @@
|
||||
|
||||
.dropdown-note-info {
|
||||
padding: 1em !important;
|
||||
|
||||
ul {
|
||||
--row-block-margin: .2em;
|
||||
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: calc(0px - var(--row-block-margin));
|
||||
margin-bottom: 12px;
|
||||
display: table;
|
||||
|
||||
li {
|
||||
display: table-row;
|
||||
|
||||
> strong {
|
||||
display: table-cell;
|
||||
padding: var(--row-block-margin) 0;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
> span {
|
||||
display: table-cell;
|
||||
user-select: text;
|
||||
padding-left: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-note-paths {
|
||||
.note-paths-widget {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.note-path-intro {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.note-path-list {
|
||||
margin: 12px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
/* Note path card */
|
||||
li {
|
||||
--border-radius: 6px;
|
||||
|
||||
position: relative;
|
||||
background: var(--card-background-color);
|
||||
padding: 8px 20px 8px 25px;
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
& + li {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Current path arrow */
|
||||
&.path-current::before {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
content: "\ee8f";
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
bottom: 0;
|
||||
font-family: "boxicons";
|
||||
font-size: .75em;
|
||||
color: var(--menu-item-icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Note path segment */
|
||||
a {
|
||||
margin-inline: 2px;
|
||||
padding-inline: 2px;
|
||||
color: currentColor;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
|
||||
/* The last segment of the note path */
|
||||
&.basename {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.backlinks-widget > .dropdown-menu {
|
||||
@@ -160,84 +71,6 @@
|
||||
|
||||
max-height: 60vh;
|
||||
overflow-y: scroll;
|
||||
|
||||
/* Backlink card */
|
||||
li {
|
||||
--border-radius: 8px;
|
||||
|
||||
max-width: 600px;
|
||||
padding: 10px 20px;
|
||||
background: var(--card-background-color);
|
||||
|
||||
& + li {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
/* Card header */
|
||||
& > span:first-child {
|
||||
display: block;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
/* Note path */
|
||||
> small {
|
||||
flex: 100%;
|
||||
order: -1;
|
||||
font-size: .65rem;
|
||||
|
||||
.note-path {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Note icon */
|
||||
> .tn-icon {
|
||||
color: var(--menu-item-icon-color);
|
||||
}
|
||||
|
||||
/* Note title */
|
||||
> a {
|
||||
margin-inline-start: 4px;
|
||||
color: currentColor;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Card content - excerpt */
|
||||
& > span:nth-child(2) > div {
|
||||
all: unset; /* TODO: Remove after disposing the old style from FloatingButtons.css */
|
||||
display: block;
|
||||
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
background: var(--quick-search-result-content-background);
|
||||
padding: 8px;
|
||||
font-size: .75rem;
|
||||
|
||||
a {
|
||||
background: transparent;
|
||||
color: var(--quick-search-result-highlight-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,16 +117,50 @@
|
||||
|
||||
}
|
||||
|
||||
div.similar-notes-widget div.similar-notes-wrapper {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
button.select-button:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout .note-info-content {
|
||||
ul {
|
||||
--row-block-margin: .2em;
|
||||
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: calc(0px - var(--row-block-margin));
|
||||
margin-bottom: 12px;
|
||||
display: table;
|
||||
|
||||
li {
|
||||
display: table-row;
|
||||
|
||||
> strong {
|
||||
display: table-cell;
|
||||
padding: var(--row-block-margin) 0;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
> span {
|
||||
display: table-cell;
|
||||
user-select: text;
|
||||
padding-left: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout div.similar-notes-widget div.similar-notes-wrapper {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout.mobile div.similar-notes-widget div.similar-notes-wrapper {
|
||||
max-height: unset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bottom-panel {
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function StatusBar() {
|
||||
similarNotesShown: activePane === "similar-notes",
|
||||
setSimilarNotesShown: (shown) => setActivePane(shown && "similar-notes")
|
||||
};
|
||||
const isHiddenNote = note?.isInHiddenSubtree();
|
||||
const isHiddenNote = note?.isHiddenCompletely();
|
||||
|
||||
return (
|
||||
<div className="status-bar">
|
||||
@@ -212,8 +212,8 @@ export function getLocaleName(locale: Locale | null | undefined) {
|
||||
|
||||
//#region Note info & Similar
|
||||
interface NoteInfoContext extends StatusBarContext {
|
||||
similarNotesShown: boolean;
|
||||
setSimilarNotesShown: (value: boolean) => void;
|
||||
similarNotesShown?: boolean;
|
||||
setSimilarNotesShown?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function NoteInfoBadge(context: NoteInfoContext) {
|
||||
@@ -225,7 +225,7 @@ export function NoteInfoBadge(context: NoteInfoContext) {
|
||||
|
||||
// Keyboard shortcut.
|
||||
useTriliumEvent("toggleRibbonTabNoteInfo", () => enabled && dropdownRef.current?.show());
|
||||
useTriliumEvent("toggleRibbonTabSimilarNotes", () => setSimilarNotesShown(!similarNotesShown));
|
||||
useTriliumEvent("toggleRibbonTabSimilarNotes", () => setSimilarNotesShown && setSimilarNotesShown(!similarNotesShown));
|
||||
|
||||
return (enabled &&
|
||||
<StatusBarDropdown
|
||||
@@ -242,8 +242,8 @@ export function NoteInfoBadge(context: NoteInfoContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function NoteInfoContent({ note, setSimilarNotesShown, noteType, dropdownRef }: NoteInfoContext & {
|
||||
dropdownRef: RefObject<BootstrapDropdown>;
|
||||
export function NoteInfoContent({ note, setSimilarNotesShown, noteType, dropdownRef }: Pick<NoteInfoContext, "note" | "setSimilarNotesShown"> & {
|
||||
dropdownRef?: RefObject<BootstrapDropdown>;
|
||||
noteType: NoteType;
|
||||
}) {
|
||||
const { metadata, ...sizeProps } = useNoteMetadata(note);
|
||||
@@ -251,7 +251,7 @@ function NoteInfoContent({ note, setSimilarNotesShown, noteType, dropdownRef }:
|
||||
const noteTypeMapping = useMemo(() => NOTE_TYPES.find(t => t.type === noteType), [ noteType ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="note-info-content">
|
||||
<ul>
|
||||
{originalFileName && <NoteInfoValue text={t("file_properties.original_file_name")} value={originalFileName} />}
|
||||
<NoteInfoValue text={t("note_info_widget.created")} value={formatDateTime(metadata?.dateCreated)} />
|
||||
@@ -262,14 +262,14 @@ function NoteInfoContent({ note, setSimilarNotesShown, noteType, dropdownRef }:
|
||||
<NoteInfoValue text={t("note_info_widget.note_size")} title={t("note_info_widget.note_size_info")} value={<NoteSizeWidget {...sizeProps} />} />
|
||||
</ul>
|
||||
|
||||
<LinkButton
|
||||
{setSimilarNotesShown && <LinkButton
|
||||
text={t("note_info_widget.show_similar_notes")}
|
||||
onClick={() => {
|
||||
dropdownRef.current?.hide();
|
||||
dropdownRef?.current?.hide();
|
||||
setSimilarNotesShown(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ function BacklinksBadge({ note, viewScope }: StatusBarContext) {
|
||||
const count = useBacklinkCount(note, viewScope?.viewMode === "default");
|
||||
return (note && count > 0 &&
|
||||
<StatusBarDropdown
|
||||
className="backlinks-badge backlinks-widget"
|
||||
className="backlinks-badge backlinks-widget tn-backlinks-widget"
|
||||
icon="bx bx-link"
|
||||
text={t("status_bar.backlinks", { count })}
|
||||
title={t("status_bar.backlinks_title", { count })}
|
||||
|
||||
139
apps/client/src/widgets/mobile_widgets/TabSwitcher.css
Normal file
139
apps/client/src/widgets/mobile_widgets/TabSwitcher.css
Normal file
@@ -0,0 +1,139 @@
|
||||
#launcher-container .mobile-tab-switcher {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tab-count);
|
||||
font-family: var(--main-font-family);
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal.tab-bar-modal {
|
||||
.modal-dialog {
|
||||
min-height: 85vh;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1em;
|
||||
|
||||
@media (min-width: 850px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.tab-card {
|
||||
background: var(--card-background-color);
|
||||
border-radius: 1em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.with-hue {
|
||||
background-color: hsl(var(--bg-hue), 8.8%, 11.2%);
|
||||
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
|
||||
}
|
||||
|
||||
&.active {
|
||||
outline: 4px solid var(--more-accented-background-color);
|
||||
background: var(--card-background-hover-color);
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0.4em 0.5em;
|
||||
border-bottom: 1px solid rgba(150, 150, 150, 0.1);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
color: var(--custom-color, inherit);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top: 1px solid rgba(150, 150, 150, 0.1);
|
||||
}
|
||||
|
||||
>.tn-icon {
|
||||
margin-inline-end: 0.4em;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
font-size: 0.9em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-preview {
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.5em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
&.type-text {
|
||||
padding: 10px;
|
||||
--ck-content-todo-list-checkmark-size: 8px;
|
||||
|
||||
p { margin-bottom: 0.2em;}
|
||||
hr { margin-block: 0.1em; height: 1px; }
|
||||
h2 { font-size: 1.20em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
h4 { font-size: 1.10em; }
|
||||
h5 { font-size: 1.05em}
|
||||
h6 { font-size: 1em; }
|
||||
ul, ol { margin: 0 }
|
||||
}
|
||||
|
||||
&.type-book,
|
||||
&.type-contentWidget,
|
||||
&.type-search,
|
||||
&.type-empty,
|
||||
&.type-relationMap,
|
||||
&.type-launcher,
|
||||
&.tab-preview-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
font-size: 500%;
|
||||
}
|
||||
}
|
||||
|
||||
&.with-split {
|
||||
.preview-placeholder {
|
||||
font-size: 250%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
.tn-link {
|
||||
color: var(--main-text-color);
|
||||
width: 40%;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
267
apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx
Normal file
267
apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import "./TabSwitcher.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ComponentChild } from "preact";
|
||||
import { createPortal, Fragment } from "preact/compat";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext, { CommandNames } from "../../components/app_context";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import contextMenu from "../../menus/context_menu";
|
||||
import { getHue, parseColor } from "../../services/css_class_manager";
|
||||
import froca from "../../services/froca";
|
||||
import { t } from "../../services/i18n";
|
||||
import type { ViewMode, ViewScope } from "../../services/link";
|
||||
import { NoteContent } from "../collections/legacy/ListOrGridView";
|
||||
import { LaunchBarActionButton } from "../launch_bar/launch_bar_widgets";
|
||||
import { ICON_MAPPINGS } from "../note_bars/CollectionProperties";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { useActiveNoteContext, useNoteIcon, useTriliumEvents } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import LinkButton from "../react/LinkButton";
|
||||
import Modal from "../react/Modal";
|
||||
|
||||
const VIEW_MODE_ICON_MAPPINGS: Record<Exclude<ViewMode, "default">, string> = {
|
||||
source: "bx bx-code",
|
||||
"contextual-help": "bx bx-help-circle",
|
||||
"note-map": "bx bxs-network-chart",
|
||||
attachments: "bx bx-paperclip",
|
||||
};
|
||||
|
||||
export default function TabSwitcher() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const mainNoteContexts = useMainNoteContexts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LaunchBarActionButton
|
||||
className="mobile-tab-switcher"
|
||||
icon="bx bx-rectangle"
|
||||
text="Tabs"
|
||||
onClick={() => setShown(true)}
|
||||
data-tab-count={mainNoteContexts.length > 99 ? "∞" : mainNoteContexts.length}
|
||||
/>
|
||||
{createPortal(<TabBarModal mainNoteContexts={mainNoteContexts} shown={shown} setShown={setShown} />, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBarModal({ mainNoteContexts, shown, setShown }: {
|
||||
mainNoteContexts: NoteContext[];
|
||||
shown: boolean;
|
||||
setShown: (newValue: boolean) => void;
|
||||
}) {
|
||||
const [ fullyShown, setFullyShown ] = useState(false);
|
||||
const selectTab = useCallback((noteContextToActivate: NoteContext) => {
|
||||
appContext.tabManager.activateNoteContext(noteContextToActivate.ntxId);
|
||||
setShown(false);
|
||||
}, [ setShown ]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="tab-bar-modal"
|
||||
size="xl"
|
||||
title={t("mobile_tab_switcher.title", { count: mainNoteContexts.length})}
|
||||
show={shown}
|
||||
onShown={() => setFullyShown(true)}
|
||||
customTitleBarButtons={[
|
||||
{
|
||||
iconClassName: "bx bx-dots-vertical-rounded",
|
||||
title: t("mobile_tab_switcher.more_options"),
|
||||
onClick(e) {
|
||||
contextMenu.show<CommandNames>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{ title: t("tab_row.new_tab"), command: "openNewTab", uiIcon: "bx bx-plus" },
|
||||
{ title: t("tab_row.reopen_last_tab"), command: "reopenLastTab", uiIcon: "bx bx-undo", enabled: appContext.tabManager.recentlyClosedTabs.length !== 0 },
|
||||
{ kind: "separator" },
|
||||
{ title: t("tab_row.close_all_tabs"), command: "closeAllTabs", uiIcon: "bx bx-trash destructive-action-icon" },
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => {
|
||||
if (command) {
|
||||
appContext.triggerCommand(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
]}
|
||||
footer={<>
|
||||
<LinkButton
|
||||
text={t("tab_row.new_tab")}
|
||||
onClick={() => {
|
||||
appContext.triggerCommand("openNewTab");
|
||||
setShown(false);
|
||||
}}
|
||||
/>
|
||||
</>}
|
||||
scrollable
|
||||
onHidden={() => {
|
||||
setShown(false);
|
||||
setFullyShown(false);
|
||||
}}
|
||||
>
|
||||
<TabBarModelContent mainNoteContexts={mainNoteContexts} selectTab={selectTab} shown={fullyShown} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBarModelContent({ mainNoteContexts, selectTab, shown }: {
|
||||
mainNoteContexts: NoteContext[];
|
||||
shown: boolean;
|
||||
selectTab: (noteContextToActivate: NoteContext) => void;
|
||||
}) {
|
||||
const activeNoteContext = useActiveNoteContext();
|
||||
const tabRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// Scroll to active tab.
|
||||
useEffect(() => {
|
||||
if (!shown || !activeNoteContext?.ntxId) return;
|
||||
const correspondingEl = tabRefs.current[activeNoteContext.ntxId];
|
||||
requestAnimationFrame(() => {
|
||||
correspondingEl?.scrollIntoView();
|
||||
});
|
||||
}, [ activeNoteContext, shown ]);
|
||||
|
||||
return (
|
||||
<div className="tabs">
|
||||
{mainNoteContexts.map((noteContext) => (
|
||||
<Tab
|
||||
key={noteContext.ntxId}
|
||||
noteContext={noteContext}
|
||||
activeNtxId={activeNoteContext.ntxId}
|
||||
selectTab={selectTab}
|
||||
containerRef={el => (tabRefs.current[noteContext.ntxId ?? ""] = el)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ noteContext, containerRef, selectTab, activeNtxId }: {
|
||||
containerRef: (el: HTMLDivElement | null) => void;
|
||||
noteContext: NoteContext;
|
||||
selectTab: (noteContextToActivate: NoteContext) => void;
|
||||
activeNtxId: string | null | undefined;
|
||||
}) {
|
||||
const { note } = noteContext;
|
||||
const colorClass = note?.getColorClass() || '';
|
||||
const workspaceTabBackgroundColorHue = getWorkspaceTabBackgroundColorHue(noteContext);
|
||||
const subContexts = noteContext.getSubContexts();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={clsx("tab-card", {
|
||||
active: noteContext.ntxId === activeNtxId,
|
||||
"with-hue": workspaceTabBackgroundColorHue !== undefined,
|
||||
"with-split": subContexts.length > 1
|
||||
})}
|
||||
onClick={() => selectTab(noteContext)}
|
||||
style={{
|
||||
"--bg-hue": workspaceTabBackgroundColorHue
|
||||
}}
|
||||
>
|
||||
{subContexts.map(subContext => (
|
||||
<Fragment key={subContext.ntxId}>
|
||||
<TabHeader noteContext={subContext} colorClass={colorClass} />
|
||||
<TabPreviewContent note={subContext.note} viewScope={subContext.viewScope} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabHeader({ noteContext, colorClass }: { noteContext: NoteContext, colorClass: string }) {
|
||||
const iconClass = useNoteIcon(noteContext.note);
|
||||
const [ navigationTitle, setNavigationTitle ] = useState<string | null>(null);
|
||||
|
||||
// Manage the title for read-only notes
|
||||
useEffect(() => {
|
||||
noteContext?.getNavigationTitle().then(setNavigationTitle);
|
||||
}, [noteContext]);
|
||||
|
||||
return (
|
||||
<header className={colorClass}>
|
||||
{noteContext.note && <Icon icon={iconClass} />}
|
||||
<span className="title">{navigationTitle ?? noteContext.note?.title ?? t("tab_row.new_tab")}</span>
|
||||
{noteContext.isMainContext() && <ActionButton
|
||||
icon="bx bx-x"
|
||||
text={t("tab_row.close_tab")}
|
||||
onClick={(e) => {
|
||||
// We are closing a tab, so we need to prevent propagation for click (activate tab).
|
||||
e.stopPropagation();
|
||||
appContext.tabManager.removeNoteContext(noteContext.ntxId);
|
||||
}}
|
||||
/>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TabPreviewContent({ note, viewScope }: {
|
||||
note: FNote | null,
|
||||
viewScope: ViewScope | undefined
|
||||
}) {
|
||||
let el: ComponentChild;
|
||||
let isPlaceholder = true;
|
||||
|
||||
if (!note) {
|
||||
el = <PreviewPlaceholder icon="bx bx-plus" />;
|
||||
} else if (note.type === "book") {
|
||||
el = <PreviewPlaceholder icon={ICON_MAPPINGS[note.getLabelValue("viewType") ?? ""] ?? "bx bx-book"} />;
|
||||
} else if (viewScope?.viewMode && viewScope.viewMode !== "default") {
|
||||
el = <PreviewPlaceholder icon={VIEW_MODE_ICON_MAPPINGS[viewScope?.viewMode ?? ""] ?? "bx bx-empty"} />;
|
||||
} else {
|
||||
el = <NoteContent
|
||||
note={note}
|
||||
highlightedTokens={undefined}
|
||||
trim
|
||||
includeArchivedNotes={false}
|
||||
/>;
|
||||
isPlaceholder = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("tab-preview", `type-${note?.type ?? "empty"}`, { "tab-preview-placeholder": isPlaceholder })}>{el}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewPlaceholder({ icon}: {
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="preview-placeholder">
|
||||
<Icon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceTabBackgroundColorHue(noteContext: NoteContext) {
|
||||
if (!noteContext.hoistedNoteId) return;
|
||||
const hoistedNote = froca.getNoteFromCache(noteContext.hoistedNoteId);
|
||||
if (!hoistedNote) return;
|
||||
|
||||
const workspaceTabBackgroundColor = hoistedNote.getWorkspaceTabBackgroundColor();
|
||||
if (!workspaceTabBackgroundColor) return;
|
||||
|
||||
try {
|
||||
const parsedColor = parseColor(workspaceTabBackgroundColor);
|
||||
if (!parsedColor) return;
|
||||
return getHue(parsedColor);
|
||||
} catch (e) {
|
||||
// Colors are non-critical, simply ignore.
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
function useMainNoteContexts() {
|
||||
const [ noteContexts, setNoteContexts ] = useState(appContext.tabManager.getMainNoteContexts());
|
||||
|
||||
useTriliumEvents([ "newNoteContextCreated", "noteContextRemoved" ] , () => {
|
||||
setNoteContexts(appContext.tabManager.getMainNoteContexts());
|
||||
});
|
||||
|
||||
return noteContexts;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.code-note-switcher-modal .dropdown-menu {
|
||||
background: none !important;
|
||||
}
|
||||
@@ -1,74 +1,238 @@
|
||||
import { useContext } from "preact/hooks";
|
||||
import appContext, { CommandMappings } from "../../components/app_context";
|
||||
import contextMenu, { MenuItem } from "../../menus/context_menu";
|
||||
import branches from "../../services/branches";
|
||||
import "./mobile_detail_menu.css";
|
||||
|
||||
import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { createPortal, useRef, useState } from "preact/compat";
|
||||
|
||||
import FNote, { NotePathRecord } from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import note_create from "../../services/note_create";
|
||||
import tree from "../../services/tree";
|
||||
import server from "../../services/server";
|
||||
import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions";
|
||||
import { getLocaleName, NoteInfoContent } from "../layout/StatusBar";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import BasicWidget from "../basic_widget";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem } from "../react/FormList";
|
||||
import { useNoteContext, useNoteProperty } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import { NoteTypeCodeNoteList, useLanguageSwitcher, useMimeTypes } from "../ribbon/BasicPropertiesTab";
|
||||
import { NoteContextMenu } from "../ribbon/NoteActions";
|
||||
import NoteActionsCustom from "../ribbon/NoteActionsCustom";
|
||||
import { NotePathsWidget, useSortedNotePaths } from "../ribbon/NotePathsTab";
|
||||
import SimilarNotesTab from "../ribbon/SimilarNotesTab";
|
||||
import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector";
|
||||
|
||||
export default function MobileDetailMenu() {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const dropdownRef = useRef<BootstrapDropdown | null>(null);
|
||||
const { note, noteContext, parentComponent, ntxId, viewScope, hoistedNoteId } = useNoteContext();
|
||||
const subContexts = noteContext?.getMainContext().getSubContexts() ?? [];
|
||||
const isMainContext = noteContext?.isMainContext();
|
||||
const [ backlinksModalShown, setBacklinksModalShown ] = useState(false);
|
||||
const [ notePathsModalShown, setNotePathsModalShown ] = useState(false);
|
||||
const [ noteInfoModalShown, setNoteInfoModalShown ] = useState(false);
|
||||
const [ similarNotesModalShown, setSimilarNotesModalShown ] = useState(false);
|
||||
const [ codeNoteSwitcherModalShown, setCodeNoteSwitcherModalShown ] = useState(false);
|
||||
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
|
||||
const backlinksCount = useBacklinkCount(note, viewScope?.viewMode === "default");
|
||||
|
||||
function closePane() {
|
||||
// Wait first for the context menu to be dismissed, otherwise the backdrop stays on.
|
||||
requestAnimationFrame(() => {
|
||||
parentComponent.triggerCommand("closeThisNoteSplit", { ntxId });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
icon="bx bx-dots-vertical-rounded"
|
||||
text=""
|
||||
onClick={(e) => {
|
||||
const ntxId = (parentComponent as BasicWidget | null)?.getClosestNtxId();
|
||||
if (!ntxId) return;
|
||||
<div style={{ contain: "none" }}>
|
||||
{note ? (
|
||||
<NoteContextMenu
|
||||
dropdownRef={dropdownRef}
|
||||
note={note} noteContext={noteContext}
|
||||
extraItems={<>
|
||||
<div className="form-list-row">
|
||||
<div className="form-list-col">
|
||||
<FormListItem
|
||||
icon="bx bx-link"
|
||||
onClick={() => setBacklinksModalShown(true)}
|
||||
disabled={backlinksCount === 0}
|
||||
>{t("status_bar.backlinks", { count: backlinksCount })}</FormListItem>
|
||||
</div>
|
||||
<div className="form-list-col">
|
||||
<FormListItem
|
||||
icon="bx bx-directions"
|
||||
onClick={() => setNotePathsModalShown(true)}
|
||||
disabled={(sortedNotePaths?.length ?? 0) <= 1}
|
||||
>{t("status_bar.note_paths", { count: sortedNotePaths?.length })}</FormListItem>
|
||||
</div>
|
||||
</div>
|
||||
<FormDropdownDivider />
|
||||
|
||||
const noteContext = appContext.tabManager.getNoteContextById(ntxId);
|
||||
const subContexts = noteContext.getMainContext().getSubContexts();
|
||||
const isMainContext = noteContext?.isMainContext();
|
||||
const note = noteContext.note;
|
||||
{noteContext && ntxId && <NoteActionsCustom note={note} noteContext={noteContext} ntxId={ntxId} />}
|
||||
<FormListItem
|
||||
onClick={() => noteContext?.notePath && note_create.createNote(noteContext.notePath)}
|
||||
icon="bx bx-plus"
|
||||
>{t("mobile_detail_menu.insert_child_note")}</FormListItem>
|
||||
{subContexts.length < 2 && <>
|
||||
<FormDropdownDivider />
|
||||
<FormListItem
|
||||
onClick={(e) => {
|
||||
// We have to manually manage the hide because otherwise the old note context gets activated.
|
||||
e.stopPropagation();
|
||||
dropdownRef.current?.hide();
|
||||
parentComponent.triggerCommand("openNewNoteSplit", { ntxId });
|
||||
}}
|
||||
icon="bx bx-dock-right"
|
||||
>{t("create_pane_button.create_new_split")}</FormListItem>
|
||||
</>}
|
||||
{!isMainContext && <>
|
||||
<FormDropdownDivider />
|
||||
<FormListItem
|
||||
icon="bx bx-x"
|
||||
onClick={closePane}
|
||||
>{t("close_pane_button.close_this_pane")}</FormListItem>
|
||||
</>}
|
||||
<FormDropdownDivider />
|
||||
{note.type === "text" && <ContentLanguageSelector note={note} />}
|
||||
{note.type === "code" && <FormListItem icon={"bx bx-code"} onClick={() => setCodeNoteSwitcherModalShown(true)}>{t("status_bar.code_note_switcher")}</FormListItem>}
|
||||
<FormListItem icon="bx bx-info-circle" onClick={() => setNoteInfoModalShown(true)}>{t("note_info_widget.title")}</FormListItem>
|
||||
<FormListItem icon="bx bx-bar-chart" onClick={() => setSimilarNotesModalShown(true)}>{t("similar_notes.title")}</FormListItem>
|
||||
<FormDropdownDivider />
|
||||
</>}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
icon="bx bx-x"
|
||||
onClick={closePane}
|
||||
text={t("close_pane_button.close_this_pane")}
|
||||
/>
|
||||
)}
|
||||
|
||||
const items: (MenuItem<keyof CommandMappings>)[] = [
|
||||
{ title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" },
|
||||
{ title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" },
|
||||
{ kind: "separator" },
|
||||
{ title: t("mobile_detail_menu.note_revisions"), command: "showRevisions", uiIcon: "bx bx-history" },
|
||||
{ kind: "separator" },
|
||||
subContexts.length < 2 && { title: t("create_pane_button.create_new_split"), command: "openNewNoteSplit", uiIcon: "bx bx-dock-right" },
|
||||
!isMainContext && { title: t("close_pane_button.close_this_pane"), command: "closeThisNoteSplit", uiIcon: "bx bx-x" }
|
||||
].filter(i => !!i) as MenuItem<keyof CommandMappings>[];
|
||||
|
||||
const lastItem = items.at(-1);
|
||||
if (lastItem && "kind" in lastItem && lastItem.kind === "separator") {
|
||||
items.pop();
|
||||
}
|
||||
|
||||
contextMenu.show<keyof CommandMappings>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === "insertChildNote") {
|
||||
note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined);
|
||||
} else if (command === "delete") {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (!notePath) {
|
||||
throw new Error("Cannot get note path to delete.");
|
||||
}
|
||||
|
||||
const branchId = await tree.getBranchIdFromUrl(notePath);
|
||||
|
||||
if (!branchId) {
|
||||
throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath }));
|
||||
}
|
||||
|
||||
if (await branches.deleteNotes([branchId]) && parentComponent) {
|
||||
parentComponent.triggerCommand("setActiveScreen", { screen: "tree" });
|
||||
}
|
||||
} else if (command && parentComponent) {
|
||||
parentComponent.triggerCommand(command, { ntxId });
|
||||
}
|
||||
},
|
||||
forcePositionOnMobile: true
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
{createPortal((
|
||||
<>
|
||||
<BacklinksModal note={note} modalShown={backlinksModalShown} setModalShown={setBacklinksModalShown} />
|
||||
<NotePathsModal note={note} modalShown={notePathsModalShown} notePath={noteContext?.notePath} sortedNotePaths={sortedNotePaths} setModalShown={setNotePathsModalShown} />
|
||||
<NoteInfoModal note={note} modalShown={noteInfoModalShown} setModalShown={setNoteInfoModalShown} />
|
||||
<SimilarNotesModal note={note} modalShown={similarNotesModalShown} setModalShown={setSimilarNotesModalShown} />
|
||||
<CodeNoteSwitcherModal note={note} modalShown={codeNoteSwitcherModalShown} setModalShown={setCodeNoteSwitcherModalShown} />
|
||||
</>
|
||||
), document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentLanguageSelector({ note }: { note: FNote | null | undefined }) {
|
||||
const { locales, DEFAULT_LOCALE, currentNoteLanguage, setCurrentNoteLanguage } = useLanguageSwitcher(note);
|
||||
const { activeLocale, processedLocales } = useProcessedLocales(locales, DEFAULT_LOCALE, currentNoteLanguage ?? DEFAULT_LOCALE.id);
|
||||
|
||||
return (
|
||||
<FormDropdownSubmenu
|
||||
icon="bx bx-globe"
|
||||
title={t("mobile_detail_menu.content_language_switcher", { language: getLocaleName(activeLocale ?? DEFAULT_LOCALE) })}
|
||||
>
|
||||
{processedLocales.map((locale, index) =>
|
||||
(typeof locale === "object") ? (
|
||||
<FormListItem
|
||||
key={locale.id}
|
||||
rtl={locale.rtl}
|
||||
checked={locale.id === currentNoteLanguage}
|
||||
onClick={() => setCurrentNoteLanguage(locale.id)}
|
||||
>{locale.name}</FormListItem>
|
||||
) : (
|
||||
<FormDropdownDivider key={`divider-${index}`} />
|
||||
)
|
||||
)}
|
||||
</FormDropdownSubmenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface WithModal {
|
||||
modalShown: boolean;
|
||||
setModalShown: (shown: boolean) => void;
|
||||
}
|
||||
|
||||
function BacklinksModal({ note, modalShown, setModalShown }: { note: FNote | null | undefined } & WithModal) {
|
||||
return (
|
||||
<Modal
|
||||
className="backlinks-modal tn-backlinks-widget"
|
||||
size="md"
|
||||
title={t("mobile_detail_menu.backlinks")}
|
||||
show={modalShown}
|
||||
onHidden={() => setModalShown(false)}
|
||||
>
|
||||
<ul className="backlinks-items">
|
||||
{note && <BacklinksList note={note} />}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NotePathsModal({ note, modalShown, notePath, sortedNotePaths, setModalShown }: { note: FNote | null | undefined, sortedNotePaths: NotePathRecord[] | undefined, notePath: string | null | undefined } & WithModal) {
|
||||
return (
|
||||
<Modal
|
||||
className="note-paths-modal"
|
||||
size="md"
|
||||
title={t("note_paths.title")}
|
||||
show={modalShown}
|
||||
onHidden={() => setModalShown(false)}
|
||||
>
|
||||
{note && (
|
||||
<NotePathsWidget
|
||||
sortedNotePaths={sortedNotePaths}
|
||||
currentNotePath={notePath}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteInfoModal({ note, modalShown, setModalShown }: { note: FNote | null | undefined } & WithModal) {
|
||||
return (
|
||||
<Modal
|
||||
className="note-info-modal"
|
||||
size="md"
|
||||
title={t("note_info_widget.title")}
|
||||
show={modalShown}
|
||||
onHidden={() => setModalShown(false)}
|
||||
>
|
||||
{note && <NoteInfoContent note={note} noteType={note.type} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SimilarNotesModal({ note, modalShown, setModalShown }: { note: FNote | null | undefined } & WithModal) {
|
||||
return (
|
||||
<Modal
|
||||
className="similar-notes-modal"
|
||||
size="md"
|
||||
title={t("similar_notes.title")}
|
||||
show={modalShown}
|
||||
onHidden={() => setModalShown(false)}
|
||||
>
|
||||
<SimilarNotesTab note={note} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeNoteSwitcherModal({ note, modalShown, setModalShown }: { note: FNote | null | undefined } & WithModal) {
|
||||
const currentNoteMime = useNoteProperty(note, "mime");
|
||||
const mimeTypes = useMimeTypes();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="code-note-switcher-modal"
|
||||
size="md"
|
||||
title={t("status_bar.code_note_switcher")}
|
||||
show={modalShown}
|
||||
onHidden={() => setModalShown(false)}
|
||||
>
|
||||
<div className="dropdown-menu static show">
|
||||
{note && <NoteTypeCodeNoteList
|
||||
currentMimeType={currentNoteMime}
|
||||
mimeTypes={mimeTypes}
|
||||
changeNoteType={(type, mime) => {
|
||||
server.put(`notes/${note.noteId}/type`, { type, mime });
|
||||
setModalShown(false);
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.collection-properties {
|
||||
padding: 0;
|
||||
padding: 0.55em 12px;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
align-items: center;
|
||||
@@ -14,7 +14,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
>div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.center-container {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right-container {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.55em 1em;
|
||||
|
||||
>div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import "./CollectionProperties.css";
|
||||
|
||||
import { t } from "i18next";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useContext, useRef } from "preact/hooks";
|
||||
import { Fragment } from "preact/jsx-runtime";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { getHelpUrlForNote } from "../../services/in_app_help";
|
||||
import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useNoteLabelWithDefault, useNoteProperty, useTriliumEvent } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config";
|
||||
import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab";
|
||||
|
||||
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
export const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
grid: "bx bxs-grid",
|
||||
list: "bx bx-list-ul",
|
||||
calendar: "bx bx-calendar",
|
||||
@@ -28,15 +26,26 @@ const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
presentation: "bx bx-rectangle"
|
||||
};
|
||||
|
||||
export default function CollectionProperties({ note }: { note: FNote }) {
|
||||
export default function CollectionProperties({ note, centerChildren, rightChildren }: {
|
||||
note: FNote;
|
||||
centerChildren?: ComponentChildren;
|
||||
rightChildren?: ComponentChildren;
|
||||
}) {
|
||||
const [ viewType, setViewType ] = useViewType(note);
|
||||
const noteType = useNoteProperty(note, "type");
|
||||
|
||||
return (
|
||||
return ([ "book", "search" ].includes(noteType ?? "") &&
|
||||
<div className="collection-properties">
|
||||
<ViewTypeSwitcher viewType={viewType} setViewType={setViewType} />
|
||||
<ViewOptions note={note} viewType={viewType} />
|
||||
<div className="spacer" />
|
||||
<HelpButton note={note} />
|
||||
<div className="left-container">
|
||||
<ViewTypeSwitcher viewType={viewType} setViewType={setViewType} />
|
||||
<ViewOptions note={note} viewType={viewType} />
|
||||
</div>
|
||||
<div className="center-container">
|
||||
{centerChildren}
|
||||
</div>
|
||||
<div className="right-container">
|
||||
{rightChildren}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -222,15 +231,3 @@ function CheckBoxPropertyView({ note, property }: { note: FNote, property: Check
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpButton({ note }: { note: FNote }) {
|
||||
const helpUrl = getHelpUrlForNote(note);
|
||||
|
||||
return (helpUrl && (
|
||||
<ActionButton
|
||||
icon="bx bx-help-circle"
|
||||
onClick={(() => openInAppHelpFromUrl(helpUrl))}
|
||||
text={t("help-button.title")}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user