Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43f15962f7 | ||
|
|
ade146f9f3 | ||
|
|
b253ec4121 | ||
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 | ||
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 | ||
|
|
7ca226d918 | ||
|
|
1cbdf704bd | ||
|
|
b5bd5ea151 | ||
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
e61c66dc26 | ||
|
|
3e8aa7f10d | ||
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 | ||
|
|
b8308d89e8 | ||
|
|
0e5b380f8c | ||
|
|
e76919ab5c | ||
|
|
84ab90fd3f | ||
|
|
87b191eb63 | ||
|
|
0def9d6d6d | ||
|
|
e708d8ee63 | ||
|
|
461001442a | ||
|
|
6b5e923e8e | ||
|
|
289606a746 | ||
|
|
9c53b3b594 | ||
|
|
d497ff8aaf | ||
|
|
51acd51cb2 | ||
|
|
b4bb3b89d6 | ||
|
|
9b51aef073 | ||
|
|
4aa0b200b9 | ||
|
|
8769b56fd1 | ||
|
|
06d2cd744f | ||
|
|
7428cc7dfb | ||
|
|
6c89d668fe | ||
|
|
2c76a4747e | ||
|
|
fd55c00015 | ||
|
|
28ef37f354 | ||
|
|
bf5a9f134d | ||
|
|
2991940ba8 | ||
|
|
54352a03d8 | ||
|
|
c4d1f12b2e | ||
|
|
aa699007f7 | ||
|
|
047a8b3c3d | ||
|
|
6098dd01b4 | ||
|
|
b16beb9af5 | ||
|
|
95565d7506 | ||
|
|
a13d17ed81 | ||
|
|
95515cac9d | ||
|
|
21dd2d1681 | ||
|
|
6d5f12c47c | ||
|
|
d01bd4ea01 | ||
|
|
bd7d66b1e8 | ||
|
|
0b9fe69819 | ||
|
|
936c38a21d | ||
|
|
e60e637437 | ||
|
|
479c957698 | ||
|
|
13fd70f944 | ||
|
|
015726f896 | ||
|
|
5e2c31598c | ||
|
|
24d1ba9f8f | ||
|
|
5345442de2 | ||
|
|
6ea2ca0ae2 | ||
|
|
6f4f526d07 | ||
|
|
13bad87500 | ||
|
|
acecc9c68d | ||
|
|
35a8501f2d | ||
|
|
09f9df968d | ||
|
|
2c58bf3d9a | ||
|
|
e063f1c73a | ||
|
|
cafc0ce1ec | ||
|
|
a192723fbd | ||
|
|
b643eeb884 | ||
|
|
eb042e5760 | ||
|
|
6251d96193 | ||
|
|
db92dabba5 | ||
|
|
2469a971ca | ||
|
|
a43bf05bf0 | ||
|
|
ffd9ebad7e | ||
|
|
d2100c8500 | ||
|
|
7e63d721bc | ||
|
|
2c774c51c6 | ||
|
|
8eba4527af | ||
|
|
181b3eb6c2 | ||
|
|
879d2f42c6 | ||
|
|
85ea26b57a | ||
|
|
800c81e598 | ||
|
|
30c3523722 | ||
|
|
761bf5b42e | ||
|
|
b10a7b1bfc | ||
|
|
313d86df23 | ||
|
|
95627cbb5c | ||
|
|
b9ea14f330 | ||
|
|
812d025a9d | ||
|
|
11cc2daa4b | ||
|
|
5c36f2aa71 | ||
|
|
987b1b9ed7 | ||
|
|
3ef0e14581 | ||
|
|
f98b3415bc | ||
|
|
16805c96aa | ||
|
|
9f2e0ba8b6 | ||
|
|
7b3609afbb | ||
|
|
84ff75f223 | ||
|
|
56dc0eefd7 | ||
|
|
eeea16531a |
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,29 +10,45 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.2)
|
||||
## Download (v1.3.4)
|
||||
|
||||
### Linux
|
||||
|
||||
#### Arch / Manjaro (AUR)
|
||||
#### AppImage
|
||||
|
||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
|
||||
#### Distro-specific packages
|
||||
|
||||
##### Fedora 43+ (DNF)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
sudo dnf config-manager addrepo \
|
||||
--from-repofile=https://repo.arkhost.com/helixnotes.repo
|
||||
sudo dnf install helix-notes
|
||||
```
|
||||
|
||||
#### Debian / Ubuntu / Mint (APT)
|
||||
##### Debian / Ubuntu / Mint (APT)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||
```
|
||||
|
||||
#### Solus (EOPKG)
|
||||
##### Arch / Manjaro (AUR)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
```
|
||||
|
||||
##### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
##### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
@@ -42,7 +58,7 @@ sudo eopkg it helixnotes
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
helix-notes = {
|
||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
||||
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
}
|
||||
};
|
||||
@@ -90,29 +106,32 @@ sudo eopkg it helixnotes
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
#### Manual package downloads
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
> ```bash
|
||||
> xattr -cr /Applications/HelixNotes.app
|
||||
> ```
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
||||
|
||||
---
|
||||
|
||||
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -175,4 +194,4 @@ pnpm tauri build
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,29 +6,33 @@
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
||||
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -44,7 +48,8 @@
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
buildInputs = with pkgs;
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
@@ -71,6 +76,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
formatter = pkgs.alejandra;
|
||||
});
|
||||
formatter = pkgs.nixfmt;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1860,6 +1860,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"rayon",
|
||||
@@ -1876,6 +1877,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-ios-vault-access",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
@@ -5201,6 +5203,15 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-ios-vault-access"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -42,6 +42,12 @@ quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = "0.3.2"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-is-fullscreen",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
|
||||
Required for notarization. These do NOT enable App Sandbox.
|
||||
Without these, the WebKit webview and bundled Rust libraries get killed by the
|
||||
hardened runtime's library validation. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
<color name="ic_launcher_background">#626ED4</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 984 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 633 B |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(85.333 85.333) scale(0.666667)">
|
||||
<line x1="178.45584" y1="178.45584" x2="333.54416" y2="333.54416" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="337.53708" y1="174.46292" x2="174.46294" y2="337.53708" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="153" cy="153" r="36" fill="#ffffff"/>
|
||||
<circle cx="359" cy="153" r="36" fill="#ffffff"/>
|
||||
<circle cx="153" cy="359" r="36" fill="#ffffff"/>
|
||||
<circle cx="359" cy="359" r="36" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"><rect
|
||||
width="512"
|
||||
height="512"
|
||||
rx="112"
|
||||
ry="112"
|
||||
fill="#626ED4"
|
||||
id="rect1" /><line
|
||||
x1="178.45584"
|
||||
y1="178.45584"
|
||||
x2="333.54416"
|
||||
y2="333.54416"
|
||||
stroke="#ffffff"
|
||||
stroke-width="25"
|
||||
stroke-opacity="0.5"
|
||||
stroke-linecap="round"
|
||||
id="line1" /><line
|
||||
x1="337.53708"
|
||||
y1="174.46292"
|
||||
x2="174.46294"
|
||||
y2="337.53708"
|
||||
stroke="#ffffff"
|
||||
stroke-width="25"
|
||||
stroke-opacity="0.5"
|
||||
stroke-linecap="round"
|
||||
id="line2" /><circle
|
||||
cx="153"
|
||||
cy="153"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle2" /><circle
|
||||
cx="359"
|
||||
cy="153"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle3" /><circle
|
||||
cx="153"
|
||||
cy="359"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle4" /><circle
|
||||
cx="359"
|
||||
cy="359"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle5" /></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"colors": {
|
||||
"bg_active": "#373a52",
|
||||
"bg_editor": "#1a1b26",
|
||||
"bg_hover": "#2f3145",
|
||||
"bg_primary": "#1a1b26",
|
||||
"bg_secondary": "#1f2029",
|
||||
"bg_tertiary": "#2a2b3d",
|
||||
"border_color": "#2f3145",
|
||||
"text_primary": "#c8cde4",
|
||||
"text_secondary": "#6b7094"
|
||||
},
|
||||
"id": "custom-1782146306429",
|
||||
"is_dark": true,
|
||||
"name": "My theme"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "tauri-plugin-ios-vault-access"
|
||||
version = "0.1.0"
|
||||
authors = ["HelixNotes"]
|
||||
description = "iOS Files folder access for HelixNotes"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
links = "tauri-plugin-ios-vault-access"
|
||||
|
||||
[dependencies]
|
||||
tauri = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version:5.3
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
platforms: [.iOS(.v14)],
|
||||
products: [
|
||||
.library(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-ios-vault-access"]
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
dependencies: [.byName(name: "Tauri")],
|
||||
path: "Sources"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
import Foundation
|
||||
import SwiftRs
|
||||
import Tauri
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private struct BookmarkRequest: Decodable {
|
||||
let bookmarkId: String
|
||||
}
|
||||
|
||||
final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate {
|
||||
private let fileManager = FileManager.default
|
||||
private let lock = NSLock()
|
||||
private var pickerInvoke: Invoke?
|
||||
private var activeBookmarkId: String?
|
||||
private var activeURL: URL?
|
||||
private var stagedBookmarkId: String?
|
||||
private var stagedURL: URL?
|
||||
|
||||
deinit {
|
||||
rollbackStagedScope()
|
||||
releaseCurrentScope()
|
||||
}
|
||||
|
||||
@objc func chooseFolder(_ invoke: Invoke) {
|
||||
DispatchQueue.main.async {
|
||||
guard self.pickerInvoke == nil else {
|
||||
invoke.reject("A folder picker is already open.")
|
||||
return
|
||||
}
|
||||
guard let viewController = self.manager.viewController else {
|
||||
self.pickerInvoke = nil
|
||||
invoke.reject("Files is unavailable right now. Try again.")
|
||||
return
|
||||
}
|
||||
self.pickerInvoke = invoke
|
||||
let picker = UIDocumentPickerViewController(
|
||||
forOpeningContentTypes: [.folder],
|
||||
asCopy: false
|
||||
)
|
||||
picker.delegate = self
|
||||
picker.allowsMultipleSelection = false
|
||||
viewController.present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func resolveFolder(_ invoke: Invoke) throws {
|
||||
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||
do {
|
||||
invoke.resolve(try resolveBookmark(request.bookmarkId))
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
@objc func releaseActive(_ invoke: Invoke) {
|
||||
rollbackStagedScope()
|
||||
releaseCurrentScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func commitStaged(_ invoke: Invoke) {
|
||||
commitStagedScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func rollbackStaged(_ invoke: Invoke) {
|
||||
rollbackStagedScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func forgetBookmark(_ invoke: Invoke) throws {
|
||||
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||
do {
|
||||
let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false)
|
||||
lock.lock()
|
||||
let isActive = activeBookmarkId == request.bookmarkId
|
||||
let isStaged = stagedBookmarkId == request.bookmarkId
|
||||
lock.unlock()
|
||||
if fileManager.fileExists(atPath: file.path) {
|
||||
try fileManager.removeItem(at: file)
|
||||
}
|
||||
if isActive {
|
||||
releaseCurrentScope()
|
||||
}
|
||||
if isStaged {
|
||||
rollbackStagedScope()
|
||||
}
|
||||
invoke.resolve()
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
func documentPicker(
|
||||
_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]
|
||||
) {
|
||||
guard let invoke = takePickerInvoke() else { return }
|
||||
guard let url = urls.first else {
|
||||
invoke.resolve(cancelledResult())
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AccessError.denied
|
||||
}
|
||||
var adopted = false
|
||||
defer {
|
||||
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
let name = try validateFolder(url)
|
||||
let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased()
|
||||
let bookmark = try url.bookmarkData(
|
||||
options: .minimalBookmark,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||
stageScope(url: url, bookmarkId: bookmarkId)
|
||||
adopted = true
|
||||
invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name))
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
takePickerInvoke()?.resolve(cancelledResult())
|
||||
}
|
||||
|
||||
private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] {
|
||||
guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing }
|
||||
|
||||
lock.lock()
|
||||
let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil
|
||||
lock.unlock()
|
||||
if let currentURL {
|
||||
return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL))
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false))
|
||||
var stale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
)
|
||||
guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied }
|
||||
var adopted = false
|
||||
defer {
|
||||
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
let name = try validateFolder(url)
|
||||
if stale {
|
||||
let refreshed = try url.bookmarkData(
|
||||
options: .minimalBookmark,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||
}
|
||||
stageScope(url: url, bookmarkId: bookmarkId)
|
||||
adopted = true
|
||||
return result(bookmarkId: bookmarkId, url: url, name: name)
|
||||
}
|
||||
|
||||
private func validateFolder(_ url: URL) throws -> String {
|
||||
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
|
||||
guard values.isDirectory == true else { throw AccessError.notFolder }
|
||||
guard try url.checkResourceIsReachable() else { throw AccessError.unavailable }
|
||||
return values.name ?? url.lastPathComponent
|
||||
}
|
||||
|
||||
private func stageScope(url: URL, bookmarkId: String) {
|
||||
lock.lock()
|
||||
let previous = stagedURL
|
||||
stagedURL = url
|
||||
stagedBookmarkId = bookmarkId
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func commitStagedScope() {
|
||||
lock.lock()
|
||||
guard let next = stagedURL else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let previous = activeURL
|
||||
activeURL = next
|
||||
activeBookmarkId = stagedBookmarkId
|
||||
stagedURL = nil
|
||||
stagedBookmarkId = nil
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func rollbackStagedScope() {
|
||||
lock.lock()
|
||||
let staged = stagedURL
|
||||
stagedURL = nil
|
||||
stagedBookmarkId = nil
|
||||
lock.unlock()
|
||||
staged?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func releaseCurrentScope() {
|
||||
lock.lock()
|
||||
let previous = activeURL
|
||||
activeURL = nil
|
||||
activeBookmarkId = nil
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func takePickerInvoke() -> Invoke? {
|
||||
let invoke = pickerInvoke
|
||||
pickerInvoke = nil
|
||||
return invoke
|
||||
}
|
||||
|
||||
private func bookmarkDirectory(create: Bool) throws -> URL {
|
||||
let base = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true)
|
||||
if create {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL {
|
||||
guard UUID(uuidString: id) != nil else { throw AccessError.missing }
|
||||
let directory = try bookmarkDirectory(create: createDirectory)
|
||||
return directory.appendingPathComponent("\(id).bookmark")
|
||||
}
|
||||
|
||||
private func existingBookmarkId(for selectedURL: URL) -> String? {
|
||||
guard
|
||||
let directory = try? bookmarkDirectory(create: true),
|
||||
let files = try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
let selectedPath = selectedURL.standardizedFileURL.path
|
||||
for file in files where file.pathExtension == "bookmark" {
|
||||
guard let data = try? Data(contentsOf: file) else { continue }
|
||||
var stale = false
|
||||
guard
|
||||
let resolved = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
),
|
||||
resolved.standardizedFileURL.path == selectedPath
|
||||
else { continue }
|
||||
let id = file.deletingPathExtension().lastPathComponent
|
||||
if UUID(uuidString: id) != nil { return id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] {
|
||||
["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false]
|
||||
}
|
||||
|
||||
private func cancelledResult() -> [String: Any] {
|
||||
["bookmarkId": "", "path": "", "name": "", "cancelled": true]
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let accessError = error as? AccessError { return accessError.localizedDescription }
|
||||
return "The selected folder is unavailable. Open Files and try again."
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccessError: LocalizedError {
|
||||
case denied
|
||||
case missing
|
||||
case notFolder
|
||||
case unavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .denied:
|
||||
return "HelixNotes no longer has access to this folder. Choose it again in Files."
|
||||
case .missing:
|
||||
return "Saved access to this folder is missing. Choose it again in Files."
|
||||
case .notFolder:
|
||||
return "The selected item is not a folder."
|
||||
case .unavailable:
|
||||
return "The selected folder is unavailable. Open Files and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_ios_vault_access")
|
||||
func initPlugin() -> Plugin {
|
||||
IosVaultAccessPlugin()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder, PluginHandle, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderSelection {
|
||||
pub bookmark_id: String,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BookmarkRequest<'a> {
|
||||
bookmark_id: &'a str,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
tauri::ios_plugin_binding!(init_plugin_ios_vault_access);
|
||||
|
||||
pub struct IosVaultAccess<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> IosVaultAccess<R> {
|
||||
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
|
||||
self.0
|
||||
.run_mobile_plugin("chooseFolder", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
|
||||
self.0
|
||||
.run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id })
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn release_active(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("releaseActive", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn commit_staged(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("commitStaged", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn rollback_staged(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("rollbackStaged", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id })
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IosVaultAccessExt<R: Runtime> {
|
||||
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
|
||||
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
|
||||
self.state::<IosVaultAccess<R>>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("ios-vault-access")
|
||||
.setup(|app, api| {
|
||||
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
|
||||
app.manage(IosVaultAccess(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -405,7 +405,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -435,10 +435,12 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
|
||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -38,11 +38,38 @@ pub fn run() {
|
||||
let close_to_tray = config.close_to_tray && show_tray;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
// Inject the compile-time platform so the frontend never sniffs the (sometimes
|
||||
// mobile-looking) WebKitGTK user-agent. (#63)
|
||||
let platform_init = format!(
|
||||
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
|
||||
cfg!(mobile),
|
||||
cfg!(target_os = "android"),
|
||||
cfg!(target_os = "ios"),
|
||||
);
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
|
||||
.js_init_script(platform_init)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.on_page_load(|webview, payload| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if webview.label() == "main"
|
||||
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
|
||||
{
|
||||
queue_macos_traffic_light_fix(&webview.window());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (webview, payload);
|
||||
})
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "ios")]
|
||||
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -98,10 +125,17 @@ pub fn run() {
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::choose_external_vault,
|
||||
commands::restore_external_vault,
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_system_themes,
|
||||
commands::set_accent_color,
|
||||
commands::save_custom_theme,
|
||||
commands::delete_custom_theme,
|
||||
commands::export_custom_theme,
|
||||
commands::import_custom_themes,
|
||||
commands::set_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
@@ -117,12 +151,14 @@ pub fn run() {
|
||||
commands::read_note,
|
||||
commands::save_note,
|
||||
commands::create_note,
|
||||
commands::duplicate_note,
|
||||
commands::create_daily_note,
|
||||
commands::rename_note,
|
||||
commands::delete_note,
|
||||
commands::move_note,
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_note_switcher_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
@@ -137,6 +173,7 @@ pub fn run() {
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::read_clipboard_image,
|
||||
commands::copy_image_to_clipboard,
|
||||
commands::save_image,
|
||||
@@ -278,9 +315,33 @@ pub fn run() {
|
||||
}));
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
let window_state_builder = tauri_plugin_window_state::Builder::default();
|
||||
#[cfg(target_os = "linux")]
|
||||
let window_state_builder = window_state_builder.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED
|
||||
| tauri_plugin_window_state::StateFlags::DECORATIONS
|
||||
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
);
|
||||
builder = builder.plugin(window_state_builder.build());
|
||||
|
||||
builder = builder.on_window_event(move |window, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if window.label() == "main"
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Resized(_)
|
||||
| tauri::WindowEvent::Focused(true)
|
||||
| tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
| tauri::WindowEvent::ThemeChanged(_)
|
||||
)
|
||||
{
|
||||
// AppKit can restore the default button Y position after window-state
|
||||
// changes. Queue this pass so it runs after AppKit finishes its layout.
|
||||
queue_macos_traffic_light_fix(window);
|
||||
}
|
||||
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
// Only hide to tray for the main window
|
||||
@@ -369,3 +430,59 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
String::from_utf8_lossy(&output).to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
||||
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||
|
||||
const TITLEBAR_HEIGHT: f64 = 34.0;
|
||||
const LEFT_INSET: f64 = 12.0;
|
||||
|
||||
let ns_window_ptr = match window.ns_window() {
|
||||
Ok(ptr) => ptr,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
|
||||
|
||||
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let window_height = ns_window.frame().size.height;
|
||||
if window_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let close_frame = NSView::frame(&close);
|
||||
let button_spacing = NSView::frame(&miniaturize).origin.x - close_frame.origin.x;
|
||||
for (index, button) in [&close, &miniaturize, &zoom].into_iter().enumerate() {
|
||||
let button_superview = match unsafe { button.superview() } {
|
||||
Some(view) => view,
|
||||
None => return,
|
||||
};
|
||||
let mut frame = NSView::frame(button);
|
||||
let top_inset = ((TITLEBAR_HEIGHT - frame.size.height) / 2.0).max(0.0);
|
||||
let mut target = frame.origin;
|
||||
target.y = window_height - top_inset - frame.size.height;
|
||||
frame.origin.y = button_superview.convertPoint_fromView(target, None).y;
|
||||
frame.origin.x = LEFT_INSET + index as f64 * button_spacing;
|
||||
button.setFrameOrigin(frame.origin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use crate::vault::watcher::VaultWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
config: Mutex::new(config),
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
vault_transition: tokio::sync::Mutex::new(()),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||
// and the manifest itself remain local-only.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
@@ -119,12 +119,12 @@ struct LocalFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
@@ -312,8 +312,9 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status().as_u16();
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||
if resp.status().is_success() || s == 405 || s == 301 {
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||
@@ -723,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_synced_relpath;
|
||||
|
||||
#[test]
|
||||
fn syncs_notebook_icon_mapping_and_assets_only() {
|
||||
for path in [
|
||||
"Notes/plan.md",
|
||||
".helixnotes/attachments/notebook-icon.png",
|
||||
".helixnotes/notebook_icons.json",
|
||||
] {
|
||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"Notes/image.png",
|
||||
".helixnotes/sync_state.json",
|
||||
".helixnotes/notebook_icons.json.bak",
|
||||
".helixnotes/attachments-old/icon.png",
|
||||
] {
|
||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,17 +50,76 @@ pub struct NoteContent {
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub bookmark_id: Option<String>,
|
||||
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_open: bool,
|
||||
#[serde(default)]
|
||||
pub sync_on_change: bool,
|
||||
#[serde(default)]
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomThemeColors {
|
||||
pub bg_primary: String,
|
||||
pub bg_secondary: String,
|
||||
pub bg_tertiary: String,
|
||||
pub bg_hover: String,
|
||||
pub bg_active: String,
|
||||
pub bg_editor: String,
|
||||
pub text_primary: String,
|
||||
pub text_secondary: String,
|
||||
pub border_color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_dark: bool,
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StartupView {
|
||||
Daily,
|
||||
QuickAccess,
|
||||
Tasks,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
pub active_vault: Option<String>,
|
||||
#[serde(default)]
|
||||
pub active_bookmark_id: Option<String>,
|
||||
pub theme: String,
|
||||
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
|
||||
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
|
||||
#[serde(default = "default_system_light_theme")]
|
||||
pub system_light_theme: String,
|
||||
#[serde(default = "default_system_dark_theme")]
|
||||
pub system_dark_theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -78,6 +137,8 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub show_note_switcher: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
@@ -144,14 +205,18 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub default_view_mode: bool,
|
||||
#[serde(default)]
|
||||
pub new_notes_in_source_mode: bool,
|
||||
#[serde(default)]
|
||||
pub show_tray_icon: bool,
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub startup_view: StartupView,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
|
||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -168,12 +233,18 @@ pub struct AppConfig {
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_themes: Vec<CustomTheme>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_outline_width() -> f64 {
|
||||
220.0
|
||||
}
|
||||
|
||||
fn default_pdf_height() -> u32 {
|
||||
600
|
||||
}
|
||||
@@ -206,12 +277,23 @@ fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-6".to_string()
|
||||
}
|
||||
|
||||
fn default_system_light_theme() -> String {
|
||||
"light".to_string()
|
||||
}
|
||||
|
||||
fn default_system_dark_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vaults: Vec::new(),
|
||||
active_vault: None,
|
||||
active_bookmark_id: None,
|
||||
theme: "system".to_string(),
|
||||
system_light_theme: default_system_light_theme(),
|
||||
system_dark_theme: default_system_dark_theme(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
font_family: None,
|
||||
@@ -220,6 +302,7 @@ impl Default for AppConfig {
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
show_note_switcher: false,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
@@ -253,9 +336,11 @@ impl Default for AppConfig {
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
new_notes_in_source_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
startup_view: StartupView::All,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
@@ -265,6 +350,7 @@ impl Default for AppConfig {
|
||||
sync_on_change: false,
|
||||
sync_interval_minutes: 0,
|
||||
last_sync_time: None,
|
||||
custom_themes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +360,8 @@ pub struct VaultState {
|
||||
pub last_open_note: Option<String>,
|
||||
pub sidebar_width: f64,
|
||||
pub notelist_width: f64,
|
||||
#[serde(default = "default_outline_width")]
|
||||
pub outline_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub notelist_collapsed: bool,
|
||||
@@ -309,6 +397,7 @@ impl Default for VaultState {
|
||||
last_open_note: None,
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
outline_width: default_outline_width(),
|
||||
sidebar_collapsed: false,
|
||||
notelist_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
@@ -345,6 +434,9 @@ pub struct FileEvent {
|
||||
pub struct ImportResult {
|
||||
pub files_converted: u64,
|
||||
pub links_converted: u64,
|
||||
pub frontmatter_normalized: u64,
|
||||
pub syntax_converted: u64,
|
||||
pub attachments_moved: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -413,3 +505,56 @@ pub struct TaskItem {
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_view_tests {
|
||||
use super::{AppConfig, StartupView};
|
||||
|
||||
#[test]
|
||||
fn serializes_supported_startup_views() {
|
||||
for (view, expected) in [
|
||||
(StartupView::All, "\"all\""),
|
||||
(StartupView::QuickAccess, "\"quickaccess\""),
|
||||
(StartupView::Tasks, "\"tasks\""),
|
||||
(StartupView::Daily, "\"daily\""),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_startup_view_falls_back_to_all_notes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
|
||||
StartupView::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_configs_default_system_theme_pair() {
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("system_light_theme");
|
||||
object.remove("system_dark_theme");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct RawFrontmatter {
|
||||
}
|
||||
|
||||
/// Try to parse a date string in multiple common formats.
|
||||
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
pub fn filename_to_title(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".md");
|
||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod frontmatter;
|
||||
pub mod import;
|
||||
pub mod operations;
|
||||
pub mod watcher;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
|
||||
use crate::types::{
|
||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
|
||||
TrashNotebookEntry, VaultState,
|
||||
};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::{DateTime, Local, Locale, Utc};
|
||||
use rayon::prelude::*;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -43,6 +48,59 @@ pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digit_run(value: &[u8], start: usize) -> (usize, &[u8]) {
|
||||
let mut end = start;
|
||||
while end < value.len() && value[end].is_ascii_digit() {
|
||||
end += 1;
|
||||
}
|
||||
let mut significant = start;
|
||||
while significant + 1 < end && value[significant] == b'0' {
|
||||
significant += 1;
|
||||
}
|
||||
(end, &value[significant..end])
|
||||
}
|
||||
|
||||
fn compare_natural_names(left: &str, right: &str) -> Ordering {
|
||||
let left_lower = left.to_lowercase();
|
||||
let right_lower = right.to_lowercase();
|
||||
let left = left_lower.as_bytes();
|
||||
let right = right_lower.as_bytes();
|
||||
let (mut left_index, mut right_index) = (0, 0);
|
||||
let mut leading_zero_order = Ordering::Equal;
|
||||
|
||||
while left_index < left.len() && right_index < right.len() {
|
||||
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
|
||||
let (left_end, left_number) = digit_run(left, left_index);
|
||||
let (right_end, right_number) = digit_run(right, right_index);
|
||||
let number_order = left_number
|
||||
.len()
|
||||
.cmp(&right_number.len())
|
||||
.then_with(|| left_number.cmp(right_number));
|
||||
if number_order != Ordering::Equal {
|
||||
return number_order;
|
||||
}
|
||||
if leading_zero_order == Ordering::Equal {
|
||||
leading_zero_order = (left_end - left_index).cmp(&(right_end - right_index));
|
||||
}
|
||||
left_index = left_end;
|
||||
right_index = right_end;
|
||||
} else {
|
||||
let character_order = left[left_index].cmp(&right[right_index]);
|
||||
if character_order != Ordering::Equal {
|
||||
return character_order;
|
||||
}
|
||||
left_index += 1;
|
||||
right_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match (left_index == left.len(), right_index == right.len()) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => leading_zero_order,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
||||
let root = Path::new(vault_path);
|
||||
if !root.exists() {
|
||||
@@ -52,11 +110,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
||||
}
|
||||
|
||||
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let root = Path::new(vault_root);
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return entries;
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
||||
@@ -70,35 +127,35 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
.collect();
|
||||
|
||||
dirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
compare_natural_names(
|
||||
&a.file_name().to_string_lossy(),
|
||||
&b.file_name().to_string_lossy(),
|
||||
)
|
||||
});
|
||||
|
||||
// Store note_count for the parent directory (used when this is the top-level call)
|
||||
// Each directory pushes its own entry with its own note_count
|
||||
for dir_entry in dirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
// Scan sibling notebooks in parallel: each subtree is independent, so
|
||||
// overlapping the per-directory read_dir calls hides FUSE latency on Android.
|
||||
// par_iter().collect() preserves the (already-sorted) order.
|
||||
let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
|
||||
paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||
NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
||||
@@ -124,32 +181,32 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
||||
}
|
||||
|
||||
subdirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
compare_natural_names(
|
||||
&a.file_name().to_string_lossy(),
|
||||
&b.file_name().to_string_lossy(),
|
||||
)
|
||||
});
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for dir_entry in subdirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
|
||||
let entries: Vec<NotebookEntry> = paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||
NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
(entries, note_count)
|
||||
}
|
||||
@@ -197,47 +254,36 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
||||
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let mut notes = Vec::new();
|
||||
// Log what read_dir sees
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
||||
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
|
||||
for (i, entry) in items.iter().enumerate().take(5) {
|
||||
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
|
||||
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
|
||||
}
|
||||
} else {
|
||||
log::info!("scan_notes: read_dir FAILED for {:?}", root);
|
||||
}
|
||||
|
||||
if notebook_path.is_some() {
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
|
||||
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect candidate .md paths first, then read their metadata in parallel.
|
||||
// Overlapping the per-file reads hides FUSE latency on Android (matches the
|
||||
// desktop path below). The previous version also did an extra read_dir purely
|
||||
// for debug logging, which doubled the directory I/O on the sandboxed FS.
|
||||
let paths: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
match fs::read_dir(root) {
|
||||
Ok(rd) => rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
for entry in WalkDir::new(root)
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
// filter_entry skips descending into hidden dirs (unlike filter)
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_type().is_file()
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
{
|
||||
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut notes: Vec<NoteEntry> = paths
|
||||
.par_iter()
|
||||
.filter_map(|path| read_note_entry_metadata_only(path, vault_root).ok())
|
||||
.collect();
|
||||
|
||||
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
return Ok(notes);
|
||||
@@ -488,6 +534,75 @@ pub fn create_note(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let parent = src
|
||||
.parent()
|
||||
.ok_or_else(|| "Note has no parent directory".to_string())?;
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
let filename = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
|
||||
let source_title = meta.title.clone();
|
||||
let base_title = format!("{} copy", source_title.trim_end());
|
||||
let mut copy_title = base_title.clone();
|
||||
let mut copy_number = 2;
|
||||
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
|
||||
while copy_path.exists() {
|
||||
copy_title = format!("{} {}", base_title, copy_number);
|
||||
copy_number += 1;
|
||||
copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
meta.id = Uuid::new_v4().to_string();
|
||||
meta.title = copy_title;
|
||||
meta.pinned = false;
|
||||
meta.created = now;
|
||||
meta.modified = now;
|
||||
|
||||
let body = retitle_leading_heading(&body, &source_title, &meta.title);
|
||||
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
|
||||
fs::write(©_path, copy_raw).map_err(|e| e.to_string())?;
|
||||
|
||||
read_note_entry(©_path, Path::new(vault_path))
|
||||
}
|
||||
|
||||
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
|
||||
let trimmed = body.trim_start();
|
||||
let leading_len = body.len() - trimmed.len();
|
||||
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
|
||||
let first_line = &trimmed[..first_line_end];
|
||||
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
|
||||
|
||||
if !(1..=6).contains(&heading_len) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
|
||||
return body.to_string();
|
||||
};
|
||||
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let mut retitled = String::with_capacity(body.len() + new_title.len());
|
||||
retitled.push_str(&body[..leading_len]);
|
||||
retitled.push_str(&"#".repeat(heading_len));
|
||||
retitled.push(' ');
|
||||
retitled.push_str(new_title);
|
||||
retitled.push_str(&trimmed[first_line_end..]);
|
||||
retitled
|
||||
}
|
||||
|
||||
fn get_system_locale() -> Locale {
|
||||
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
||||
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
||||
@@ -1157,16 +1272,25 @@ pub fn save_attachment(vault_path: &str, name: &str, data: &[u8]) -> Result<Stri
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_notebook_icon_key(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
}
|
||||
|
||||
pub fn load_notebook_icons(
|
||||
vault_path: &str,
|
||||
) -> Result<std::collections::HashMap<String, String>, String> {
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
if icons_path.exists() {
|
||||
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(std::collections::HashMap::new())
|
||||
if !icons_path.exists() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
|
||||
let icons: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||
Ok(icons
|
||||
.into_iter()
|
||||
.map(|(path, icon)| (normalize_notebook_icon_key(&path), icon))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn set_notebook_icon(
|
||||
@@ -1175,12 +1299,13 @@ pub fn set_notebook_icon(
|
||||
icon_relative: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut icons = load_notebook_icons(vault_path)?;
|
||||
let notebook_key = normalize_notebook_icon_key(notebook_relative);
|
||||
match icon_relative {
|
||||
Some(icon) => {
|
||||
icons.insert(notebook_relative.to_string(), icon.to_string());
|
||||
icons.insert(notebook_key, icon.to_string());
|
||||
}
|
||||
None => {
|
||||
icons.remove(notebook_relative);
|
||||
icons.remove(¬ebook_key);
|
||||
}
|
||||
}
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
@@ -1222,6 +1347,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const NOTE_SWITCHER_RECENT_LIMIT: usize = 6;
|
||||
|
||||
pub fn get_note_switcher_titles(
|
||||
vault_path: &str,
|
||||
recent_paths: &[String],
|
||||
) -> Result<Vec<NoteTitleEntry>, String> {
|
||||
let vault_root = Path::new(vault_path);
|
||||
if !vault_root.is_dir() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT);
|
||||
|
||||
for requested_path in recent_paths {
|
||||
if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT {
|
||||
break;
|
||||
}
|
||||
|
||||
let path = Path::new(requested_path);
|
||||
let Ok(relative) = path.strip_prefix(vault_root) else {
|
||||
continue;
|
||||
};
|
||||
let safe_relative = !relative.as_os_str().is_empty()
|
||||
&& relative.components().all(|component| match component {
|
||||
Component::Normal(name) => !is_hidden(Path::new(name)),
|
||||
Component::CurDir => true,
|
||||
_ => false,
|
||||
});
|
||||
if !safe_relative
|
||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
|
||||
|| !path.is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = relative.to_path_buf();
|
||||
if !seen.insert(relative_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = read_note_entry_fast(path, vault_root) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
titles.push(NoteTitleEntry {
|
||||
title: entry.meta.title,
|
||||
path: relative_path.to_string_lossy().replace('\\', "/"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(titles)
|
||||
}
|
||||
|
||||
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
let list = load_quick_access(vault_path)?;
|
||||
let vault_root = Path::new(vault_path);
|
||||
@@ -1230,7 +1408,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String
|
||||
for relative in &list {
|
||||
let full_path = vault_root.join(relative);
|
||||
if full_path.exists() {
|
||||
if let Ok(entry) = read_note_entry(&full_path, vault_root) {
|
||||
if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) {
|
||||
notes.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -1249,3 +1427,200 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
||||
};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn compares_numeric_segments_anywhere_in_names() {
|
||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||
assert_eq!(
|
||||
names,
|
||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
||||
);
|
||||
assert_eq!(
|
||||
compare_natural_names("Class 02", "Class 2b"),
|
||||
std::cmp::Ordering::Less
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_notebooks_with_numeric_names_naturally() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-natural-sort-test-{}", Uuid::new_v4()));
|
||||
let nested = vault.join("Classes");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
let expected = ["5g", "6g", "7g", "8g", "9g", "10g", "11g", "12g"];
|
||||
for name in ["10g", "11g", "12g", "5g", "6g", "7g", "8g", "9g"] {
|
||||
fs::create_dir(vault.join(name)).unwrap();
|
||||
fs::create_dir(nested.join(name)).unwrap();
|
||||
}
|
||||
|
||||
let notebooks = scan_notebooks(&vault.to_string_lossy()).unwrap();
|
||||
let root_names: Vec<_> = notebooks
|
||||
.iter()
|
||||
.filter(|notebook| notebook.name != "Classes")
|
||||
.map(|notebook| notebook.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(root_names, expected);
|
||||
let nested_names: Vec<_> = notebooks
|
||||
.iter()
|
||||
.find(|notebook| notebook.name == "Classes")
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.map(|notebook| notebook.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(nested_names, expected);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_and_removes_builtin_notebook_icons() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
|
||||
|
||||
set_notebook_icon(&vault_path, r"Projects\Client", Some("builtin:briefcase")).unwrap();
|
||||
let stored: std::collections::HashMap<String, String> = serde_json::from_str(
|
||||
&fs::read_to_string(helixnotes_dir(&vault_path).join("notebook_icons.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stored.contains_key("Projects/Client"));
|
||||
assert!(!stored.contains_key(r"Projects\Client"));
|
||||
|
||||
let icons = load_notebook_icons(&vault_path).unwrap();
|
||||
assert_eq!(
|
||||
icons.get("Projects/Client").map(String::as_str),
|
||||
Some("builtin:briefcase")
|
||||
);
|
||||
|
||||
set_notebook_icon(&vault_path, "Projects/Client", None).unwrap();
|
||||
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_notebook_icon_keys_when_loading() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let icons_path = helixnotes_dir(&vault_path).join("notebook_icons.json");
|
||||
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
|
||||
fs::write(&icons_path, r#"{"Projects\\Client":"builtin:folder"}"#).unwrap();
|
||||
|
||||
let icons = load_notebook_icons(&vault_path).unwrap();
|
||||
assert_eq!(
|
||||
icons.get("Projects/Client").map(String::as_str),
|
||||
Some("builtin:folder")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_only_requested_note_switcher_titles() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let mut note_paths = Vec::new();
|
||||
for index in 0..8 {
|
||||
let path = vault.join(format!("Note {index}.md"));
|
||||
fs::write(
|
||||
&path,
|
||||
format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"),
|
||||
)
|
||||
.unwrap();
|
||||
note_paths.push(path);
|
||||
}
|
||||
fs::write(
|
||||
vault.join("Unrelated.md"),
|
||||
"---\ntitle: Unrelated\n---\n\nMust not be loaded.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outside =
|
||||
std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4()));
|
||||
fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap();
|
||||
|
||||
let requested = vec![
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
vault.join("Missing.md").to_string_lossy().into_owned(),
|
||||
outside.to_string_lossy().into_owned(),
|
||||
note_paths[1].to_string_lossy().into_owned(),
|
||||
note_paths[2].to_string_lossy().into_owned(),
|
||||
note_paths[3].to_string_lossy().into_owned(),
|
||||
note_paths[4].to_string_lossy().into_owned(),
|
||||
note_paths[5].to_string_lossy().into_owned(),
|
||||
note_paths[6].to_string_lossy().into_owned(),
|
||||
note_paths[7].to_string_lossy().into_owned(),
|
||||
];
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let titles = get_note_switcher_titles(&vault_path, &requested).unwrap();
|
||||
assert_eq!(titles.len(), 6);
|
||||
assert_eq!(
|
||||
titles
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"Note 0.md",
|
||||
"Note 1.md",
|
||||
"Note 2.md",
|
||||
"Note 3.md",
|
||||
"Note 4.md",
|
||||
"Note 5.md",
|
||||
]
|
||||
);
|
||||
assert!(titles.iter().all(|entry| entry.title != "Unrelated"));
|
||||
assert!(titles.iter().all(|entry| entry.title != "Outside"));
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
fs::remove_file(outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4()));
|
||||
let notebook = vault.join("Projects");
|
||||
fs::create_dir_all(¬ebook).unwrap();
|
||||
let source_path = notebook.join("Project.md");
|
||||
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
|
||||
fs::write(&source_path, source_raw).unwrap();
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
|
||||
assert_eq!(first.meta.title, "Project copy");
|
||||
assert_eq!(second.meta.title, "Project copy 2");
|
||||
assert_eq!(first.meta.tags, vec!["work"]);
|
||||
assert!(!first.meta.pinned);
|
||||
assert_ne!(first.meta.id, "source-id");
|
||||
assert_ne!(first.meta.id, second.meta.id);
|
||||
assert_eq!(first.relative_path, "Projects/Project copy.md");
|
||||
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
|
||||
|
||||
let first_raw = fs::read_to_string(&first.path).unwrap();
|
||||
assert!(first_raw.contains("aliases:\n- Plan"));
|
||||
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
|
||||
assert!(!first_raw.contains("\n# Project\n"));
|
||||
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WatcherBackend {
|
||||
Recommended,
|
||||
Poll,
|
||||
}
|
||||
|
||||
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||
if is_ios {
|
||||
WatcherBackend::Poll
|
||||
} else {
|
||||
WatcherBackend::Recommended
|
||||
}
|
||||
}
|
||||
|
||||
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||
match backend {
|
||||
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum VaultWatcher {
|
||||
Recommended(RecommendedWatcher),
|
||||
Poll(PollWatcher),
|
||||
}
|
||||
|
||||
impl VaultWatcher {
|
||||
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||
match self {
|
||||
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||
let mut watcher = match backend {
|
||||
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||
),
|
||||
WatcherBackend::Poll => {
|
||||
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
@@ -61,6 +104,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
};
|
||||
let _ = app.emit("file-changed", &fe);
|
||||
}
|
||||
|
||||
// On mobile, throttle event emission to prevent IPC flooding
|
||||
// on FUSE filesystems where Syncthing/other apps generate
|
||||
// constant file activity that blocks the Tauri command channel.
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File watcher error: {}", e);
|
||||
@@ -71,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_uses_polling_backend() {
|
||||
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_keep_recommended_backend() {
|
||||
assert_eq!(
|
||||
watcher_backend_for_target(false),
|
||||
WatcherBackend::Recommended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_backends_use_the_expected_intervals() {
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Poll),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.4",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
@@ -55,6 +55,9 @@
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,21 @@
|
||||
--danger-hover: #d43d3d;
|
||||
--success: #40c057;
|
||||
--warning: #fab005;
|
||||
/* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */
|
||||
--callout-note: 68, 138, 255;
|
||||
--callout-abstract: 0, 191, 188;
|
||||
--callout-info: 0, 184, 212;
|
||||
--callout-todo: 0, 184, 212;
|
||||
--callout-tip: 0, 191, 188;
|
||||
--callout-success: 8, 185, 78;
|
||||
--callout-question: 236, 191, 0;
|
||||
--callout-warning: 236, 117, 0;
|
||||
--callout-failure: 233, 49, 71;
|
||||
--callout-danger: 233, 49, 71;
|
||||
--callout-bug: 233, 49, 71;
|
||||
--callout-example: 120, 82, 238;
|
||||
--callout-quote: 158, 158, 158;
|
||||
--callout-custom: 124, 134, 156;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
@@ -204,6 +219,20 @@
|
||||
--danger-hover: #eba0ac;
|
||||
--success: #a6e3a1;
|
||||
--warning: #f9e2af;
|
||||
--callout-note: 100, 160, 255;
|
||||
--callout-abstract: 60, 209, 205;
|
||||
--callout-info: 60, 200, 224;
|
||||
--callout-todo: 60, 200, 224;
|
||||
--callout-tip: 60, 209, 205;
|
||||
--callout-success: 80, 205, 120;
|
||||
--callout-question: 240, 205, 80;
|
||||
--callout-warning: 245, 150, 60;
|
||||
--callout-failure: 240, 95, 115;
|
||||
--callout-danger: 240, 95, 115;
|
||||
--callout-bug: 240, 95, 115;
|
||||
--callout-example: 165, 135, 245;
|
||||
--callout-quote: 150, 150, 162;
|
||||
--callout-custom: 150, 160, 182;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
@@ -1113,10 +1142,6 @@ body.resizing {
|
||||
/* ── Mobile (Android/iOS) ── */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
NoteContent,
|
||||
NoteEntry,
|
||||
NoteMeta,
|
||||
@@ -14,14 +15,24 @@ import type {
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
StartupView,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
return invoke("open_vault", { path });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string): Promise<void> {
|
||||
return invoke("remove_vault", { path });
|
||||
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
|
||||
return invoke("choose_external_vault");
|
||||
}
|
||||
|
||||
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
|
||||
return invoke("restore_external_vault", { bookmarkId });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
|
||||
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
@@ -32,10 +43,30 @@ export async function setTheme(theme: string): Promise<void> {
|
||||
return invoke("set_theme", { theme });
|
||||
}
|
||||
|
||||
export async function setSystemThemes(light: string, dark: string): Promise<void> {
|
||||
return invoke("set_system_themes", { light, dark });
|
||||
}
|
||||
|
||||
export async function setAccentColor(color: string): Promise<void> {
|
||||
return invoke("set_accent_color", { color });
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
|
||||
return invoke("save_custom_theme", { theme });
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(id: string): Promise<void> {
|
||||
return invoke("delete_custom_theme", { id });
|
||||
}
|
||||
|
||||
export async function exportCustomTheme(id: string, path: string): Promise<void> {
|
||||
return invoke("export_custom_theme", { id, path });
|
||||
}
|
||||
|
||||
export async function importCustomThemes(path: string): Promise<CustomTheme[]> {
|
||||
return invoke("import_custom_themes", { path });
|
||||
}
|
||||
|
||||
export async function setFontSize(size: number): Promise<void> {
|
||||
return invoke("set_font_size", { size });
|
||||
}
|
||||
@@ -114,6 +145,10 @@ export async function createNote(
|
||||
return invoke("create_note", { notebookRelative, title });
|
||||
}
|
||||
|
||||
export async function duplicateNote(path: string): Promise<NoteEntry> {
|
||||
return invoke("duplicate_note", { path });
|
||||
}
|
||||
|
||||
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||
return invoke("create_daily_note", { date: date ?? null });
|
||||
}
|
||||
@@ -144,6 +179,12 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_all_note_titles");
|
||||
}
|
||||
|
||||
export async function getNoteSwitcherTitles(
|
||||
recentPaths: string[],
|
||||
): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
@@ -190,6 +231,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
||||
return invoke("save_vault_state", { vaultState });
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
return invoke("copy_text_to_clipboard", { text });
|
||||
}
|
||||
|
||||
export async function readClipboardImage(): Promise<number[]> {
|
||||
return invoke("read_clipboard_image");
|
||||
}
|
||||
@@ -234,10 +279,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
newNotesInSourceMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
showNoteSwitcher: boolean,
|
||||
startupView: StartupView,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
@@ -259,10 +307,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
newNotesInSourceMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
showNoteSwitcher,
|
||||
startupView,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
import {
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
outlineWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
theme,
|
||||
resolvedTheme,
|
||||
customThemes,
|
||||
focusMode,
|
||||
readOnly,
|
||||
activeNote,
|
||||
@@ -38,6 +41,7 @@
|
||||
rootNoteCount,
|
||||
viewMode,
|
||||
sortMode,
|
||||
groupNotesByDate,
|
||||
tasksLayout,
|
||||
tasksHideCompleted,
|
||||
tasksOnlyFlagged,
|
||||
@@ -51,20 +55,22 @@
|
||||
notebookOrder,
|
||||
noteOrder,
|
||||
syncState,
|
||||
platformIsMobile
|
||||
platformIsMobile,
|
||||
activeVaultConfig,
|
||||
} from '$lib/stores/app';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
||||
import type { StartupTarget } from '$lib/utils/startup-view';
|
||||
|
||||
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||
for (const nb of list) {
|
||||
@@ -79,6 +85,38 @@
|
||||
let noteList: NoteList;
|
||||
let editor: Editor;
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||
if (target.mode === 'notebook') {
|
||||
const vault = $appConfig?.active_vault;
|
||||
const notebook = target.notebookPath === '' && vault
|
||||
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
|
||||
: findNotebookByPath($notebooks, target.notebookPath);
|
||||
if (!notebook) return false;
|
||||
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = notebook;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.mode === 'tag') {
|
||||
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
|
||||
$viewMode = 'tag';
|
||||
$activeTag = target.tag;
|
||||
$activeNotebook = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
|
||||
$viewMode = target.mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
let unlistenOpenFile: (() => void) | null = null;
|
||||
|
||||
// Mobile editor header helpers
|
||||
@@ -120,16 +158,16 @@
|
||||
}
|
||||
|
||||
function syncConfigured(): boolean {
|
||||
return get(appConfig)?.sync_provider === 'webdav';
|
||||
return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
|
||||
}
|
||||
|
||||
// Auto-sync on a timer (only when configured and an interval is set).
|
||||
async function checkScheduledSync() {
|
||||
const config = get(appConfig);
|
||||
if (config?.sync_provider !== 'webdav') return;
|
||||
const mins = config.sync_interval_minutes ?? 0;
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (vc?.sync_provider !== 'webdav') return;
|
||||
const mins = vc.sync_interval_minutes ?? 0;
|
||||
if (!mins || get(syncState).running) return;
|
||||
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
|
||||
const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
|
||||
if (Date.now() - last >= mins * 60 * 1000) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
@@ -202,6 +240,7 @@
|
||||
last_open_note: $activeNotePath,
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
outline_width: $outlineWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
@@ -209,6 +248,7 @@
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
sort_mode: $sortMode,
|
||||
group_notes_by_date: $groupNotesByDate,
|
||||
last_view_mode: $viewMode,
|
||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||
last_tag: $activeTag,
|
||||
@@ -248,14 +288,32 @@
|
||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||
let taskNoteOpened = $state(false);
|
||||
|
||||
function handleNoteSelected(path: string, content: string) {
|
||||
function handleNoteSelected(path: string, content: string, task?: TaskItem) {
|
||||
// Selecting a real vault note exits viewer mode
|
||||
$viewerNote = null;
|
||||
taskNoteOpened = true;
|
||||
editor?.loadNote(path, content);
|
||||
editor?.loadNote(path, content, task);
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
|
||||
editor?.flushSave();
|
||||
try {
|
||||
const content = await readNote(path);
|
||||
$viewerNote = null;
|
||||
$activeNote = content;
|
||||
$activeNotePath = path;
|
||||
$editorDirty = false;
|
||||
handleNoteSelected(path, content.content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to switch note:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
@@ -411,6 +469,28 @@
|
||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
||||
}
|
||||
|
||||
async function trashOpenNote(path: string): Promise<boolean> {
|
||||
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
|
||||
try {
|
||||
await deleteNote(path);
|
||||
if (Object.hasOwn($noteOrder, path)) {
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
$notes = $notes.filter((note) => note.path !== path);
|
||||
if ($activeNotePath === path) {
|
||||
$activeNote = null;
|
||||
$activeNotePath = null;
|
||||
}
|
||||
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to move open note to Trash:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||
@@ -426,6 +506,13 @@
|
||||
editor?.addLinkFromToolbar();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||
e.preventDefault();
|
||||
editor?.moveOpenNoteToTrash();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
@@ -440,7 +527,8 @@
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
@@ -498,23 +586,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
applyTheme($theme);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$collapsedNotebooks;
|
||||
persistState();
|
||||
@@ -528,6 +599,7 @@
|
||||
$effect(() => {
|
||||
$sidebarCollapsed;
|
||||
$notelistCollapsed;
|
||||
$outlineWidth;
|
||||
persistState();
|
||||
});
|
||||
|
||||
@@ -544,6 +616,7 @@
|
||||
|
||||
$effect(() => {
|
||||
$sortMode;
|
||||
$groupNotesByDate;
|
||||
$tasksLayout;
|
||||
$tasksHideCompleted;
|
||||
$tasksOnlyFlagged;
|
||||
@@ -567,6 +640,7 @@
|
||||
const state = await loadVaultState();
|
||||
$sidebarWidth = state.sidebar_width;
|
||||
$notelistWidth = state.notelist_width;
|
||||
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||
$sidebarCollapsed = state.sidebar_collapsed;
|
||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||
@@ -574,6 +648,7 @@
|
||||
$notebookOrder = state.notebook_order ?? {};
|
||||
$noteOrder = state.note_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
|
||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
||||
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||
@@ -584,39 +659,53 @@
|
||||
lastTag = state.last_tag ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||
|
||||
// On mobile, prefetch last-opened note so first tap is instant
|
||||
let prefetchPromise: Promise<any> | null = null;
|
||||
if (isMobile && lastNotePath) {
|
||||
if (isMobile && restoreLastSession && lastNotePath) {
|
||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||
}
|
||||
|
||||
applyTheme($theme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ($theme === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
// Full-vault attachment scans cause navigation stalls on mobile storage.
|
||||
// Mobile users can run the same cleanup explicitly from the Info panel.
|
||||
if (!isMobile) {
|
||||
setTimeout(async () => {
|
||||
if (get(editorDirty)) return; // skip if user is actively editing
|
||||
try {
|
||||
const orphans = await findOrphanedAttachments();
|
||||
if (orphans.length > 0) {
|
||||
if (get(editorDirty)) return; // re-check after async scan
|
||||
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
if (moved > 0) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const startupTarget = resolveStartupTarget({
|
||||
startupView: $appConfig?.startup_view,
|
||||
restoreLastSession,
|
||||
lastViewMode,
|
||||
lastNotebook,
|
||||
lastTag
|
||||
});
|
||||
if (!(await applyStartupTarget(startupTarget))) {
|
||||
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||
}
|
||||
|
||||
// Reopen the last note only when session restoration is enabled.
|
||||
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
@@ -625,7 +714,6 @@
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
if (isMobile) {
|
||||
@@ -664,7 +752,7 @@
|
||||
}
|
||||
}
|
||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||
}, 3000);
|
||||
}, 10000);
|
||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||
debouncedRefresh();
|
||||
});
|
||||
@@ -694,15 +782,16 @@
|
||||
|
||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
||||
unlistenSync.push(await listen('sync-done', async () => {
|
||||
syncState.set({ running: false, error: null });
|
||||
const cur = get(appConfig);
|
||||
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
|
||||
try {
|
||||
appConfig.set(await getAppConfig());
|
||||
} catch {}
|
||||
}));
|
||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||
|
||||
// Sync when the vault opens (if enabled)
|
||||
if (syncConfigured() && get(appConfig)?.sync_on_open) {
|
||||
if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
|
||||
@@ -712,8 +801,8 @@
|
||||
|
||||
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
||||
unsubDirty = editorDirty.subscribe((d) => {
|
||||
const config = get(appConfig);
|
||||
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
|
||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
||||
}
|
||||
@@ -829,7 +918,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $appConfig?.sync_provider === 'webdav'}
|
||||
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
||||
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
||||
@@ -856,6 +945,13 @@
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -872,10 +968,10 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -919,7 +1015,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||
{/if}
|
||||
<div class="app-layout">
|
||||
{#if !$focusMode}
|
||||
@@ -933,7 +1029,7 @@
|
||||
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
@@ -952,7 +1048,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||
<div class="tasks-editor-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -1114,7 +1210,13 @@
|
||||
.mobile-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
env(safe-area-inset-top, 0px)
|
||||
env(safe-area-inset-right, 0px)
|
||||
env(safe-area-inset-bottom, 0px)
|
||||
env(safe-area-inset-left, 0px);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
action: () => {
|
||||
$theme = 'system';
|
||||
setTheme('system');
|
||||
applyTheme('system');
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
@@ -187,7 +186,7 @@
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (t === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getGraphData } from '$lib/api';
|
||||
import { activeNotePath, appConfig } from '$lib/stores/app';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let { onclose, onnavigate }: {
|
||||
onclose: () => void;
|
||||
onnavigate: (path: string, title: string) => void;
|
||||
} = $props();
|
||||
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>(null!);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state('');
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { src, alt = '', onclose }: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 5;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const TAP_MOVE_THRESHOLD = 6;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type ViewerGesture =
|
||||
| { kind: 'pan'; start: Point; pan: Point }
|
||||
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||
|
||||
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||
let imageElement = $state<HTMLImageElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let scale = $state(1);
|
||||
let panX = $state(0);
|
||||
let panY = $state(0);
|
||||
let interacting = $state(false);
|
||||
let gesture: ViewerGesture | null = null;
|
||||
let touchMoved = false;
|
||||
let lastTapAt = 0;
|
||||
let zoomPercent = $derived(Math.round(scale * 100));
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function midpoint(first: Touch, second: Touch): Point {
|
||||
return {
|
||||
x: (first.clientX + second.clientX) / 2,
|
||||
y: (first.clientY + second.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function touchDistance(first: Touch, second: Touch): number {
|
||||
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||
}
|
||||
|
||||
function panLimits(nextScale: number): Point {
|
||||
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||
return {
|
||||
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||
const limits = panLimits(clampedScale);
|
||||
scale = clampedScale;
|
||||
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
applyTransform(MIN_SCALE, 0, 0);
|
||||
}
|
||||
|
||||
function zoomAt(point: Point, nextScale: number) {
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const imageX = (point.x - centerX - panX) / scale;
|
||||
const imageY = (point.y - centerY - panY) / scale;
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - imageX * nextScale,
|
||||
point.y - centerY - imageY * nextScale,
|
||||
);
|
||||
}
|
||||
|
||||
function beginPan(touch: Touch) {
|
||||
gesture = {
|
||||
kind: 'pan',
|
||||
start: { x: touch.clientX, y: touch.clientY },
|
||||
pan: { x: panX, y: panY },
|
||||
};
|
||||
}
|
||||
|
||||
function beginPinch(first: Touch, second: Touch) {
|
||||
const point = midpoint(first, second);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
distance: Math.max(1, touchDistance(first, second)),
|
||||
imagePoint: {
|
||||
x: (point.x - centerX - panX) / scale,
|
||||
y: (point.y - centerY - panY) / scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = true;
|
||||
if (event.touches.length >= 2) {
|
||||
touchMoved = true;
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
touchMoved = false;
|
||||
beginPan(event.touches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||
if (gesture?.kind !== 'pinch') return;
|
||||
const point = midpoint(event.touches[0], event.touches[1]);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const nextScale = clamp(
|
||||
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||
);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||
const dx = event.touches[0].clientX - gesture.start.x;
|
||||
const dy = event.touches[0].clientY - gesture.start.y;
|
||||
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
beginPan(event.touches[0]);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
if (!touchMoved && event.changedTouches.length > 0) {
|
||||
const now = performance.now();
|
||||
const touch = event.changedTouches[0];
|
||||
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||
lastTapAt = 0;
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchCancel(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
touchMoved = false;
|
||||
applyTransform(scale, panX, panY);
|
||||
}
|
||||
|
||||
function attachTouchGestures(node: HTMLElement) {
|
||||
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchmove', handleTouchMove);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
closeButton?.focus();
|
||||
const handleResize = () => applyTransform(scale, panX, panY);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="image-viewer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="image-viewer-canvas"
|
||||
bind:this={canvasElement}
|
||||
use:attachTouchGestures
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
bind:this={imageElement}
|
||||
class:interacting
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable="false"
|
||||
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||
onload={resetViewer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="image-viewer-reset"
|
||||
onclick={resetViewer}
|
||||
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v6h6" />
|
||||
</svg>
|
||||
<span>{zoomPercent}%</span>
|
||||
</button>
|
||||
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: #06080c;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.image-viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img {
|
||||
display: block;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 160ms ease-out;
|
||||
will-change: transform;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img.interacting {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls {
|
||||
position: absolute;
|
||||
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls button {
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(20, 23, 30, 0.86);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-viewer-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-viewer-reset:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.image-viewer-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image-viewer-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 23, 30, 0.82);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-viewer-canvas img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, appConfig } from '$lib/stores/app';
|
||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import { showInfo } from '$lib/stores/app';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
@@ -15,9 +12,9 @@
|
||||
bindingsEqual,
|
||||
type ActionDef,
|
||||
} from '$lib/keybindings';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
// Customizable shortcuts, grouped for display.
|
||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||
@@ -84,57 +81,21 @@
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let trashing = $state(false);
|
||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
||||
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||
|
||||
function close() {
|
||||
$showInfo = false;
|
||||
activeTab = 'shortcuts';
|
||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($showInfo) {
|
||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||
} else {
|
||||
stats = null;
|
||||
orphans = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function scanOrphans() {
|
||||
scanning = true;
|
||||
try { orphans = await findOrphanedAttachments(); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { scanning = false; }
|
||||
}
|
||||
async function trashOrphans() {
|
||||
if (!orphans || orphans.length === 0) return;
|
||||
trashing = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
orphans = null;
|
||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { trashing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $showInfo}
|
||||
@@ -144,7 +105,7 @@
|
||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
<button class="close-btn" onclick={close}>
|
||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -176,49 +137,7 @@
|
||||
<p class="app-version">v{appVersion}</p>
|
||||
<p class="app-description">A local markdown note-taking app.</p>
|
||||
|
||||
{#if stats}
|
||||
<div class="info-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes</span>
|
||||
<span class="stat-value">{stats.total_notes}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments</span>
|
||||
<span class="stat-value">{stats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes size</span>
|
||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments size</span>
|
||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-total">
|
||||
<span class="stat-label">Total vault size</span>
|
||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<div class="info-cleanup">
|
||||
{#if orphans === null}
|
||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphans as o}
|
||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
@@ -273,6 +192,7 @@
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
@@ -416,39 +336,6 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.stat-total .stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-credits {
|
||||
margin-top: 20px;
|
||||
@@ -480,71 +367,6 @@
|
||||
.info-link:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.info-cleanup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cleanup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.cleanup-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cleanup-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.shortcuts-section {
|
||||
width: 100%;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
activeNotebook,
|
||||
activeTag,
|
||||
sortMode,
|
||||
groupNotesByDate,
|
||||
editorDirty,
|
||||
quickAccessPaths,
|
||||
appConfig,
|
||||
@@ -22,6 +23,7 @@
|
||||
getNotes,
|
||||
readNote,
|
||||
createNote,
|
||||
duplicateNote,
|
||||
deleteNote,
|
||||
renameNote,
|
||||
saveNote,
|
||||
@@ -37,17 +39,20 @@
|
||||
getAllTags,
|
||||
createDailyNote
|
||||
} from '$lib/api';
|
||||
import { formatRelativeTime, formatDate } from '$lib/utils/time';
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string) => void;
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
@@ -55,8 +60,6 @@
|
||||
} = $props();
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
let multiSelectMode = $state(false);
|
||||
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
||||
let trashBusy = $state<string | null>(null);
|
||||
@@ -213,6 +216,24 @@
|
||||
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
||||
|
||||
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
|
||||
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
|
||||
let noteGroups = $derived.by(() => {
|
||||
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
|
||||
const pinned: NoteEntry[] = [];
|
||||
const rest: NoteEntry[] = [];
|
||||
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
|
||||
const groups: { label: string; notes: NoteEntry[] }[] = [];
|
||||
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
|
||||
let cur: { label: string; notes: NoteEntry[] } | null = null;
|
||||
for (const n of rest) {
|
||||
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
|
||||
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
|
||||
cur.notes.push(n);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Virtual scroll
|
||||
let listContainer = $state<HTMLDivElement>(null!);
|
||||
let scrollTop = $state(0);
|
||||
@@ -247,6 +268,19 @@
|
||||
if (listContainer) listContainer.scrollTop = 0;
|
||||
});
|
||||
|
||||
// Notes can also be removed from this list by a drop handled in the sidebar.
|
||||
$effect(() => {
|
||||
if (selectedPaths.size === 0) return;
|
||||
const availablePaths = new Set($notes.map((note) => note.path));
|
||||
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
|
||||
if (remaining.size === selectedPaths.size) return;
|
||||
if (remaining.size === 0) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectedPaths = remaining;
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||
$effect(() => {
|
||||
$quickAccessPaths;
|
||||
@@ -391,13 +425,14 @@
|
||||
$activeNote = content;
|
||||
$activeNotePath = task.note_path;
|
||||
$editorDirty = false;
|
||||
onNoteSelected(task.note_path, content.content);
|
||||
onNoteSelected(task.note_path, content.content, task);
|
||||
} catch (e) {
|
||||
console.error('Failed to open task note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateNote() {
|
||||
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||
if ($viewMode === 'daily') {
|
||||
const today = new Date();
|
||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
@@ -423,14 +458,47 @@
|
||||
const entry = await createNote(nbRelative, 'Untitled');
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
await refresh();
|
||||
await selectNote(entry);
|
||||
$notes = [entry, ...$notes];
|
||||
onBeforeNoteSwitch();
|
||||
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||
$activeNotePath = entry.path;
|
||||
$editorDirty = false;
|
||||
onNoteSelected(entry.path, '\n');
|
||||
onNoteCreated();
|
||||
} catch (e) {
|
||||
console.error('Failed to create note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
if ($activeNotePath === note.path && $editorDirty) {
|
||||
const saved = await onBeforeNoteDuplicate();
|
||||
if (!saved) {
|
||||
console.error('Failed to save note before duplicating');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await duplicateNote(note.path);
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||
await selectNote(entry);
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListDoubleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||
e.stopPropagation();
|
||||
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
|
||||
handleCreateNote();
|
||||
}
|
||||
|
||||
async function handleRename(note: NoteEntry) {
|
||||
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
||||
editingNote = null;
|
||||
@@ -474,6 +542,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleRestore(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
@@ -749,6 +818,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isFiled(note: NoteEntry): boolean {
|
||||
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (!vaultRoot) return false;
|
||||
const normalizedPath = note.path.replace(/\\/g, '/');
|
||||
const noteDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
|
||||
return noteDir !== vaultRoot;
|
||||
}
|
||||
|
||||
function unfileNote(note: NoteEntry) {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) handleMoveNote(note, vaultRoot);
|
||||
}
|
||||
|
||||
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
@@ -949,7 +1031,14 @@
|
||||
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if $viewMode !== 'trash'}
|
||||
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
|
||||
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
|
||||
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
@@ -965,8 +1054,14 @@
|
||||
<span class="selection-count">{selectedPaths.size} selected</span>
|
||||
<div class="selection-actions">
|
||||
{#if $viewMode === 'trash'}
|
||||
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
|
||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
|
||||
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Restore
|
||||
</button>
|
||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete} title="Delete permanently">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
{:else}
|
||||
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
||||
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
||||
@@ -1047,7 +1142,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||
{#if $viewMode === 'tasks'}
|
||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||
{/if}
|
||||
@@ -1089,6 +1185,8 @@
|
||||
<div class="empty-state">
|
||||
{#if $viewMode === 'trash'}
|
||||
<p>Trash is empty</p>
|
||||
{:else if $viewMode === 'quickaccess'}
|
||||
<p>No starred notes</p>
|
||||
{:else}
|
||||
<p>No notes yet</p>
|
||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||
@@ -1127,9 +1225,7 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div style="height: {topPad}px"></div>
|
||||
{#each visibleNotes as note, i (note.path)}
|
||||
{@const noteIndex = startIndex + i}
|
||||
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
|
||||
{#if editingNote === note.path}
|
||||
<div class="note-item active">
|
||||
<input
|
||||
@@ -1194,11 +1290,10 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
}
|
||||
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||
? selectedPaths
|
||||
: [note.path];
|
||||
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
@@ -1281,15 +1376,39 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $viewMode === 'trash'}
|
||||
<span class="trash-row-actions">
|
||||
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
</span>
|
||||
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if groupingActive}
|
||||
{#each noteGroups as group (group.label)}
|
||||
<div class="note-list-section">{group.label}</div>
|
||||
{#each group.notes as note (note.path)}
|
||||
{@render noteRow(note, -1)}
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<div style="height: {topPad}px"></div>
|
||||
{#each visibleNotes as note, i (note.path)}
|
||||
{@render noteRow(note, startIndex + i)}
|
||||
{/each}
|
||||
<div style="height: {bottomPad}px"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||
<!-- Batch context menu -->
|
||||
{#if $viewMode === 'trash'}
|
||||
@@ -1453,12 +1572,26 @@
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||
</svg>
|
||||
Duplicate Note
|
||||
</button>
|
||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
Move to...
|
||||
</button>
|
||||
{#if isFiled(contextMenu.note)}
|
||||
<button onclick={() => unfileNote(contextMenu!.note)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M9 13h6"/>
|
||||
</svg>
|
||||
Unfile Note
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isMobile}
|
||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -1581,17 +1714,19 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
.note-list-section {
|
||||
padding: 14px 12px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
.note-list-section:first-child {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
@@ -1605,6 +1740,7 @@
|
||||
text-align: left;
|
||||
margin-bottom: 1px;
|
||||
contain: content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
@@ -1651,6 +1787,9 @@
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
@@ -1665,6 +1804,37 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.trash-row-actions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
.note-item:hover .trash-row-actions {
|
||||
display: flex;
|
||||
}
|
||||
.trash-row-btn {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.trash-row-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.trash-row-btn.danger:hover {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
.note-list.mobile .trash-row-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.selection-action.danger {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||
@@ -2079,6 +2249,11 @@
|
||||
padding: 4px 8px 180px;
|
||||
}
|
||||
|
||||
.note-list.mobile .list-content.tasks-mode {
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-list.mobile .note-item {
|
||||
padding: 14px 16px;
|
||||
min-height: 56px;
|
||||
@@ -2145,13 +2320,15 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu {
|
||||
.context-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import {
|
||||
buildNoteSwitcherRequestPaths,
|
||||
buildNoteSwitcherSections,
|
||||
type NoteSwitcherNote,
|
||||
type NoteSwitcherRow,
|
||||
type NoteSwitcherSections
|
||||
} from '$lib/utils/note-switcher';
|
||||
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
|
||||
|
||||
let { onSelectNote = async () => false }: {
|
||||
onSelectNote?: (path: string) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let wrapper: HTMLDivElement;
|
||||
let trigger: HTMLButtonElement;
|
||||
let popover = $state<HTMLDivElement | null>(null);
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let selectingPath = $state<string | null>(null);
|
||||
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||
let loadGeneration = 0;
|
||||
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isInsideVault(path: string, vaultPath: string): boolean {
|
||||
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
|
||||
}
|
||||
|
||||
function vaultRelativePath(path: string, vaultPath: string): string {
|
||||
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
|
||||
}
|
||||
|
||||
function joinVaultPath(vaultPath: string, relativePath: string): string {
|
||||
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
|
||||
const root = vaultPath.replace(/[\\/]+$/, '');
|
||||
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
|
||||
}
|
||||
|
||||
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!relativePath || relativePath.split('/').includes('..')) return null;
|
||||
const path = joinVaultPath(vaultPath, relativePath);
|
||||
if (!isInsideVault(path, vaultPath)) return null;
|
||||
return { path, title: entry.title, relativePath };
|
||||
}
|
||||
|
||||
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
if (!isInsideVault(entry.path, vaultPath)) return null;
|
||||
return {
|
||||
path: entry.path,
|
||||
title: entry.meta.title,
|
||||
relativePath: entry.relative_path.replace(/\\/g, '/')
|
||||
};
|
||||
}
|
||||
|
||||
async function focusInitialRow() {
|
||||
await tick();
|
||||
if (!open) return;
|
||||
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
|
||||
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
|
||||
(current ?? first ?? popover)?.focus();
|
||||
}
|
||||
|
||||
async function refreshSections(generation: number) {
|
||||
const vaultPath = $appConfig?.active_vault;
|
||||
if (!vaultPath) {
|
||||
sections = { recent: [], quickAccess: [] };
|
||||
loadedVaultPath = null;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||
getNoteSwitcherTitles(requestPaths),
|
||||
getQuickAccess()
|
||||
]);
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
|
||||
if (titlesResult.status === 'rejected') {
|
||||
console.error('Failed to load notes for note switcher:', titlesResult.reason);
|
||||
}
|
||||
if (quickAccessResult.status === 'rejected') {
|
||||
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
|
||||
}
|
||||
|
||||
const knownNotes = titlesResult.status === 'fulfilled'
|
||||
? titlesResult.value
|
||||
.map((entry) => titleEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && isInsideVault(currentPath, vaultPath)) {
|
||||
const currentKey = normalizedPath(currentPath);
|
||||
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
|
||||
knownNotes.push({
|
||||
path: currentPath,
|
||||
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
|
||||
relativePath: vaultRelativePath(currentPath, vaultPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
|
||||
? quickAccessResult.value
|
||||
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
for (const note of quickAccessNotes) {
|
||||
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
|
||||
knownNotes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
sections = buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
historyPaths: $navHistory.stack,
|
||||
knownNotes,
|
||||
quickAccessNotes
|
||||
});
|
||||
loadedVaultPath = vaultPath;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
}
|
||||
|
||||
function refreshAfterPopoverPaint(generation: number) {
|
||||
requestAnimationFrame(() => {
|
||||
window.setTimeout(() => {
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
void refreshSections(generation);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSwitcher() {
|
||||
if (open) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultPath = $appConfig?.active_vault ?? null;
|
||||
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||
open = true;
|
||||
loading = !hasCachedSections;
|
||||
selectingPath = null;
|
||||
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||
loadGeneration += 1;
|
||||
const generation = loadGeneration;
|
||||
if (hasCachedSections) void focusInitialRow();
|
||||
refreshAfterPopoverPaint(generation);
|
||||
}
|
||||
|
||||
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
if (restoreTriggerFocus) {
|
||||
await tick();
|
||||
trigger?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectNote(row: NoteSwitcherRow) {
|
||||
if (selectingPath !== null) return;
|
||||
selectingPath = row.path;
|
||||
try {
|
||||
if (await onSelectNote(row.path)) await closeSwitcher(false);
|
||||
} finally {
|
||||
selectingPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopoverKeydown(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
|
||||
if (buttons.length === 0) return;
|
||||
event.preventDefault();
|
||||
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
|
||||
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
|
||||
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
|
||||
let nextIndex: number;
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = buttons.length - 1;
|
||||
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
|
||||
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const handleOutsideMouseDown = (event: MouseEvent) => {
|
||||
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void closeSwitcher(true);
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="note-switcher" bind:this={wrapper}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
class="switcher-trigger"
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onclick={toggleSwitcher}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="7" y="4" width="12" height="14" rx="2" />
|
||||
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={popover}
|
||||
class="switcher-popover"
|
||||
role="dialog"
|
||||
aria-label="Note switcher"
|
||||
tabindex="-1"
|
||||
onkeydown={handlePopoverKeydown}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="switcher-status">Loading notes…</div>
|
||||
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
|
||||
<div class="switcher-status">No notes available</div>
|
||||
{:else}
|
||||
{#each [
|
||||
{ label: 'Recent', rows: sections.recent },
|
||||
{ label: 'Quick Access', rows: sections.quickAccess }
|
||||
] as section (section.label)}
|
||||
{#if section.rows.length > 0}
|
||||
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
|
||||
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
|
||||
{#each section.rows as row (row.path)}
|
||||
<div class="note-row" class:current-row={row.current}>
|
||||
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
|
||||
<button
|
||||
class="note-primary"
|
||||
type="button"
|
||||
aria-current={row.current ? 'true' : undefined}
|
||||
disabled={selectingPath !== null}
|
||||
onclick={() => selectNote(row)}
|
||||
>
|
||||
<span class="note-title">{row.title}</span>
|
||||
<span class="note-folder">{row.folder}</span>
|
||||
</button>
|
||||
<button
|
||||
class="open-window"
|
||||
type="button"
|
||||
aria-label={`Open ${row.title} in new window`}
|
||||
title={`Open ${row.title} in new window`}
|
||||
onclick={() => openNoteWindow(row.path, row.title)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="4" width="13" height="13" rx="2" />
|
||||
<path d="M10 14L20 4m-6 0h6v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-switcher {
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.switcher-trigger:hover,
|
||||
.switcher-trigger[aria-expanded='true'] {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-trigger:focus-visible,
|
||||
.note-primary:focus-visible,
|
||||
.open-window:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.switcher-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
max-height: min(420px, calc(100vh - 58px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-section + .switcher-section {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
padding: 5px 8px 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.note-row:hover,
|
||||
.note-row:focus-within {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.note-row.current-row {
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.current-marker {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.note-primary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 6px 38px 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-row .note-primary {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.note-primary:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.note-title,
|
||||
.note-folder {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.note-folder {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.open-window {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.note-row:hover .open-window,
|
||||
.note-row:focus-within .open-window {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.open-window:hover {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-status {
|
||||
padding: 22px 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Editor from './Editor.svelte';
|
||||
import {
|
||||
appConfig,
|
||||
activeNote,
|
||||
activeNotePath,
|
||||
editorDirty,
|
||||
readOnly,
|
||||
sourceMode,
|
||||
theme
|
||||
sourceMode
|
||||
} from '$lib/stores/app';
|
||||
import { readNote } from '$lib/api';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
@@ -17,12 +18,12 @@
|
||||
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const appWebview = getCurrentWebview();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
let unlistenUiScale: (() => void) | null = null;
|
||||
let maximized = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
@@ -36,20 +37,6 @@
|
||||
return () => { unlisten.then(fn => fn()); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const t = $theme || 'system';
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
});
|
||||
|
||||
let lastMouseDown = 0;
|
||||
const RESIZE_EDGE = 6;
|
||||
|
||||
@@ -84,7 +71,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUiScale(scale: number) {
|
||||
try {
|
||||
await appWebview.setZoom(scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale to note window:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
|
||||
void applyUiScale(event.payload);
|
||||
});
|
||||
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
$activeNote = content;
|
||||
@@ -103,8 +103,11 @@
|
||||
if (!$editorDirty) {
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
// Ignore the file-watcher echo of our own save; only reload genuine external edits.
|
||||
if (content.content.trim() !== (editor?.getCurrentBody() ?? '').trim()) {
|
||||
$activeNote = content;
|
||||
editor?.loadNote(notePath, content.content);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFileChange?.();
|
||||
unlistenUiScale?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { NotebookIconId } from '$lib/utils/notebook-icons';
|
||||
|
||||
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if icon === 'book'}
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
|
||||
{:else if icon === 'folder'}
|
||||
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
|
||||
{:else if icon === 'briefcase'}
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
|
||||
{:else if icon === 'home'}
|
||||
<path d="m3 11 9-8 9 8" />
|
||||
<path d="M5 10v11h14V10M9 21v-7h6v7" />
|
||||
{:else if icon === 'star'}
|
||||
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
|
||||
{:else if icon === 'heart'}
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
|
||||
{:else if icon === 'lightbulb'}
|
||||
<path d="M9 18h6M10 22h4" />
|
||||
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
|
||||
{:else if icon === 'code'}
|
||||
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
|
||||
{:else if icon === 'flask'}
|
||||
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
|
||||
<path d="M7 16h10" />
|
||||
{:else if icon === 'graduation'}
|
||||
<path d="m2 9 10-5 10 5-10 5Z" />
|
||||
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
|
||||
{:else if icon === 'calendar'}
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
|
||||
{:else if icon === 'tasks'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
|
||||
{:else if icon === 'archive'}
|
||||
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
|
||||
{:else if icon === 'globe'}
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
{:else if icon === 'plane'}
|
||||
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
|
||||
{:else}
|
||||
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
|
||||
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
|
||||
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView, notebooks, activeNotebook, activeTag, viewMode } from '$lib/stores/app';
|
||||
import { searchNotes, readNote } from '$lib/api';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import type { SearchResult } from '$lib/types';
|
||||
import type { SearchResult, NotebookEntry } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
@@ -83,7 +84,16 @@
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
function findNotebookByPath(nbs: NotebookEntry[], target: string): NotebookEntry | null {
|
||||
for (const nb of nbs) {
|
||||
if (nb.path === target) return nb;
|
||||
if (nb.children.length) {
|
||||
const found = findNotebookByPath(nb.children, target);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openResult(result: SearchResult) {
|
||||
try {
|
||||
@@ -92,6 +102,19 @@
|
||||
$activeNotePath = result.path;
|
||||
$editorDirty = false;
|
||||
$showSearch = false;
|
||||
// Reveal the note in the notes list: switch to its notebook (or All Notes).
|
||||
const sep = result.path.includes('\\') ? '\\' : '/';
|
||||
const parentDir = result.path.slice(0, result.path.lastIndexOf(sep));
|
||||
const nb = parentDir ? findNotebookByPath($notebooks, parentDir) : null;
|
||||
if (nb) {
|
||||
$activeNotebook = nb;
|
||||
$activeTag = null;
|
||||
$viewMode = 'notebook';
|
||||
} else {
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
$viewMode = 'all';
|
||||
}
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
} catch (e) {
|
||||
console.error('Failed to open search result:', e);
|
||||
|
||||
@@ -26,13 +26,22 @@
|
||||
import { readFile } from '@tauri-apps/plugin-fs';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { NotebookEntry } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||
import {
|
||||
NOTEBOOK_ICON_OPTIONS,
|
||||
decodeBuiltinNotebookIcon,
|
||||
encodeBuiltinNotebookIcon,
|
||||
normalizeNotebookIconKey,
|
||||
type NotebookIconId
|
||||
} from '$lib/utils/notebook-icons';
|
||||
|
||||
let { onViewChanged = () => {} }: {
|
||||
onViewChanged?: () => void;
|
||||
} = $props();
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
|
||||
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
|
||||
@@ -53,6 +62,15 @@
|
||||
let dropTargetPath = $state<string | null>(null);
|
||||
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
||||
let draggedNotebookPath = $state<string | null>(null);
|
||||
let nbPointer: { src: string } | null = null;
|
||||
|
||||
// Notebook paths arrive from Rust with native separators (backslashes on Windows), so path math must be separator-agnostic.
|
||||
const norm = (p: string) => (p ?? '').replace(/\\/g, '/');
|
||||
const lastSep = (p: string) => Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
const parentOf = (p: string) => { const i = lastSep(p); return i < 0 ? '' : p.slice(0, i); };
|
||||
const baseOf = (p: string) => p.slice(lastSep(p) + 1);
|
||||
const joinPath = (parent: string, name: string) => parent + (parent.includes('\\') ? '\\' : '/') + name;
|
||||
const isDescendant = (child: string, anc: string) => child.startsWith(anc + '/') || child.startsWith(anc + '\\');
|
||||
|
||||
// Apply manual ordering recursively when in 'manual' sort mode.
|
||||
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
||||
@@ -69,15 +87,11 @@
|
||||
}
|
||||
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
|
||||
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
||||
let iconPickerNotebook = $state<NotebookEntry | null>(null);
|
||||
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
function expandNotebook(nb: NotebookEntry) {
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||
e.stopPropagation();
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
@@ -143,6 +157,10 @@
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (!vault) return;
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
||||
if (isMobile) {
|
||||
onViewChanged();
|
||||
return;
|
||||
}
|
||||
selectAllNotes();
|
||||
return;
|
||||
}
|
||||
@@ -153,22 +171,15 @@
|
||||
}
|
||||
|
||||
async function selectNotebook(nb: NotebookEntry) {
|
||||
const hasVisibleChildren = nb.children.length > 0;
|
||||
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
|
||||
// Deselect if clicking the already-active notebook
|
||||
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||
if (hasVisibleChildren) {
|
||||
if (wasCollapsed) {
|
||||
await expandNotebook(nb);
|
||||
} else {
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
||||
}
|
||||
if (isMobile) {
|
||||
onViewChanged();
|
||||
return;
|
||||
}
|
||||
selectAllNotes();
|
||||
return;
|
||||
}
|
||||
if (hasVisibleChildren) await expandNotebook(nb);
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = nb;
|
||||
$activeTag = null;
|
||||
@@ -340,50 +351,64 @@
|
||||
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
dropTargetPath = null;
|
||||
const notePath = e.dataTransfer?.getData('text/plain');
|
||||
if (!notePath) return;
|
||||
// Don't move if already in this notebook
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
if (noteDir === nb.path) return;
|
||||
const payload = e.dataTransfer?.getData('text/plain') ?? '';
|
||||
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
|
||||
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
|
||||
if (notePaths.length === 0) return;
|
||||
|
||||
const movedPaths = new Map<string, string>();
|
||||
for (const notePath of notePaths) {
|
||||
try {
|
||||
const newPath = await moveNote(notePath, nb.path);
|
||||
$notes = $notes.filter(n => n.path !== notePath);
|
||||
if ($activeNotePath === notePath) {
|
||||
$activeNotePath = newPath;
|
||||
$activeNote = await readNote(newPath);
|
||||
movedPaths.set(notePath, await moveNote(notePath, nb.path));
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', notePath, e);
|
||||
}
|
||||
}
|
||||
if (movedPaths.size === 0) return;
|
||||
|
||||
$notes = $notes.filter((note) => !movedPaths.has(note.path));
|
||||
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
|
||||
if (activeNewPath) {
|
||||
$activeNotePath = activeNewPath;
|
||||
try {
|
||||
$activeNote = await readNote(activeNewPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload moved note:', e);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||
e.preventDefault();
|
||||
dropTargetPath = null;
|
||||
const srcPath = e.dataTransfer?.getData('text/plain');
|
||||
if (!srcPath) return;
|
||||
if (srcPath) await nestNotebook(srcPath, destPath);
|
||||
}
|
||||
|
||||
async function nestNotebook(srcPath: string, destPath: string) {
|
||||
dropTargetPath = null;
|
||||
dropPosition = null;
|
||||
draggedNotebookPath = null;
|
||||
// Don't move onto self or descendant
|
||||
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return;
|
||||
if (destPath === srcPath || isDescendant(destPath, srcPath)) return;
|
||||
// Don't move if already in that parent
|
||||
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
||||
const parentDir = parentOf(srcPath);
|
||||
if (parentDir === destPath) return;
|
||||
try {
|
||||
const oldName = srcPath.split('/').pop() || '';
|
||||
const newBasePath = destPath + '/' + oldName;
|
||||
const oldName = baseOf(srcPath);
|
||||
const newBasePath = joinPath(destPath, oldName);
|
||||
await moveNotebook(srcPath, destPath);
|
||||
// Update collapsedNotebooks paths
|
||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||
if (p === srcPath) return newBasePath;
|
||||
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length);
|
||||
if (isDescendant(p, srcPath)) return newBasePath + p.slice(srcPath.length);
|
||||
return p;
|
||||
});
|
||||
// Update active notebook/note if inside the moved notebook
|
||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
||||
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||
selectAllNotes();
|
||||
}
|
||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
||||
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
||||
$activeNotePath = newNotePath;
|
||||
$activeNote = await readNote(newNotePath);
|
||||
@@ -395,10 +420,74 @@
|
||||
}
|
||||
}
|
||||
|
||||
function nbStopDragListeners() {
|
||||
window.removeEventListener('pointermove', nbPointerMove);
|
||||
window.removeEventListener('pointerup', nbDragEnd);
|
||||
window.removeEventListener('pointercancel', nbDragEnd);
|
||||
window.removeEventListener('mouseup', nbDragEnd);
|
||||
}
|
||||
|
||||
function nbHandleDown(e: PointerEvent, nb: NotebookEntry) {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
nbStopDragListeners();
|
||||
nbPointer = { src: nb.path };
|
||||
draggedNotebookPath = nb.path;
|
||||
// Capture helps touch hold the gesture; on Windows mouse it makes WebView2 swallow the release, so skip it there.
|
||||
if (e.pointerType === 'touch') {
|
||||
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch {}
|
||||
}
|
||||
window.addEventListener('pointermove', nbPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', nbDragEnd);
|
||||
window.addEventListener('pointercancel', nbDragEnd);
|
||||
window.addEventListener('mouseup', nbDragEnd);
|
||||
}
|
||||
|
||||
function nbPointerMove(e: PointerEvent) {
|
||||
if (!nbPointer) return;
|
||||
e.preventDefault();
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||
if (el?.closest('[data-nb-root]')) { dropTargetPath = '__root__'; dropPosition = null; return; }
|
||||
const row = el?.closest('[data-nb-path]') as HTMLElement | null;
|
||||
const path = row?.getAttribute('data-nb-path');
|
||||
if (!path) { dropTargetPath = null; dropPosition = null; return; }
|
||||
const src = nbPointer.src;
|
||||
if (path === src || path.startsWith(src + '/')) { dropTargetPath = null; dropPosition = null; return; }
|
||||
const rect = row!.getBoundingClientRect();
|
||||
const relY = (e.clientY - rect.top) / rect.height;
|
||||
const pos: 'above' | 'into' | 'below' = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
|
||||
if (pos === 'into' && src.substring(0, src.lastIndexOf('/')) === path) { dropTargetPath = null; dropPosition = null; return; }
|
||||
dropTargetPath = path;
|
||||
dropPosition = pos;
|
||||
}
|
||||
|
||||
function nbDragEnd() {
|
||||
if (!nbPointer) return;
|
||||
nbStopDragListeners();
|
||||
const src = nbPointer.src;
|
||||
const target = dropTargetPath;
|
||||
const pos = dropPosition;
|
||||
nbPointer = null;
|
||||
draggedNotebookPath = null;
|
||||
dropTargetPath = null;
|
||||
dropPosition = null;
|
||||
if (!target) return;
|
||||
if (target === '__root__') {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) nestNotebook(src, vaultRoot);
|
||||
} else if (pos === 'above' || pos === 'below') {
|
||||
handleNotebookReorder(src, target, pos);
|
||||
} else if (pos === 'into') {
|
||||
nestNotebook(src, target);
|
||||
}
|
||||
}
|
||||
|
||||
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
|
||||
if (parentPath === vaultRoot) return tree;
|
||||
const target = norm(parentPath);
|
||||
if (target === norm(vaultRoot)) return tree;
|
||||
for (const nb of tree) {
|
||||
if (nb.path === parentPath) return nb.children;
|
||||
if (norm(nb.path) === target) return nb.children;
|
||||
if (nb.children.length > 0) {
|
||||
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
||||
if (found.length > 0) return found;
|
||||
@@ -412,41 +501,39 @@
|
||||
dropPosition = null;
|
||||
draggedNotebookPath = null;
|
||||
if (srcPath === targetPath) return;
|
||||
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant
|
||||
if (isDescendant(targetPath, srcPath)) return; // can't move into descendant
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return;
|
||||
|
||||
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
||||
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
||||
const srcParent = parentOf(srcPath);
|
||||
const targetParent = parentOf(targetPath);
|
||||
let actualSrcPath = srcPath;
|
||||
|
||||
try {
|
||||
// Step 1: if different parents, move src into target's parent first
|
||||
if (srcParent !== targetParent) {
|
||||
const oldName = srcPath.split('/').pop() || '';
|
||||
const newPath = targetParent === vaultRoot
|
||||
? vaultRoot + '/' + oldName
|
||||
: targetParent + '/' + oldName;
|
||||
const oldName = baseOf(srcPath);
|
||||
const newPath = joinPath(targetParent, oldName);
|
||||
await moveNotebook(srcPath, targetParent);
|
||||
// Migrate paths in collapsedNotebooks
|
||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||
if (p === srcPath) return newPath;
|
||||
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length);
|
||||
if (isDescendant(p, srcPath)) return newPath + p.slice(srcPath.length);
|
||||
return p;
|
||||
});
|
||||
// Migrate paths in notebookOrder
|
||||
const migrated: Record<string, number> = {};
|
||||
for (const [p, v] of Object.entries($notebookOrder)) {
|
||||
if (p === srcPath) migrated[newPath] = v;
|
||||
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v;
|
||||
else if (isDescendant(p, srcPath)) migrated[newPath + p.slice(srcPath.length)] = v;
|
||||
else migrated[p] = v;
|
||||
}
|
||||
$notebookOrder = migrated;
|
||||
// Update active notebook/note
|
||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
||||
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||
selectAllNotes();
|
||||
}
|
||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
||||
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
||||
$activeNotePath = newNotePath;
|
||||
$activeNote = await readNote(newNotePath);
|
||||
@@ -492,21 +579,40 @@
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
async function handleSetIcon(nb: NotebookEntry) {
|
||||
async function openIconPicker(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = nb;
|
||||
await tick();
|
||||
iconPickerElement?.focus();
|
||||
}
|
||||
|
||||
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
|
||||
try {
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
const value = encodeBuiltinNotebookIcon(icon);
|
||||
await setNotebookIcon(key, value);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: value };
|
||||
iconPickerNotebook = null;
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomIcon(nb: NotebookEntry) {
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
|
||||
});
|
||||
if (!selected) return;
|
||||
const filePath = typeof selected === 'string' ? selected : selected;
|
||||
// Read the file and save as attachment
|
||||
const data = await readFile(filePath as string);
|
||||
const fileName = (filePath as string).split('/').pop() || 'icon.png';
|
||||
const filePath = selected as string;
|
||||
const data = await readFile(filePath);
|
||||
const fileName = baseOf(filePath) || 'icon.png';
|
||||
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
||||
await setNotebookIcon(nb.relative_path, iconRelative);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, iconRelative);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
@@ -514,17 +620,21 @@
|
||||
|
||||
async function handleRemoveIcon(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
await setNotebookIcon(nb.relative_path, null);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, null);
|
||||
const icons = { ...$notebookIcons };
|
||||
delete icons[key];
|
||||
$notebookIcons = icons;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
||||
const iconPath = $notebookIcons[nb.relative_path];
|
||||
if (!iconPath) return null;
|
||||
const iconPath = $notebookIcons[normalizeNotebookIconKey(nb.relative_path)];
|
||||
if (!iconPath || iconPath.startsWith('builtin:')) return null;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return null;
|
||||
return convertFileSrc(`${vaultRoot}/${iconPath}`);
|
||||
@@ -544,6 +654,14 @@
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function openNotebookMenu(e: Event, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
@@ -576,9 +694,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -675,11 +793,13 @@
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" ondblclick={startNewNotebookFromSidebar}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="section-header"
|
||||
class:drop-target={dropTargetPath === '__root__'}
|
||||
data-nb-root
|
||||
ondragover={(e) => {
|
||||
if (draggedNotebookPath) {
|
||||
e.preventDefault();
|
||||
@@ -739,11 +859,12 @@
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||
onclick={selectUnfiled}
|
||||
style="padding-left: 4px"
|
||||
>
|
||||
<span style="width:13px;flex-shrink:0"></span>
|
||||
<span style="width:14px;flex-shrink:0"></span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
||||
</button>
|
||||
@@ -823,8 +944,11 @@
|
||||
</aside>
|
||||
|
||||
{#if contextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
|
||||
New Sub-notebook
|
||||
@@ -833,16 +957,10 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
|
||||
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
|
||||
Set Icon
|
||||
{$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
|
||||
</button>
|
||||
{#if $notebookIcons[contextMenu.notebook.relative_path]}
|
||||
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
Remove Icon
|
||||
</button>
|
||||
{/if}
|
||||
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
|
||||
Delete
|
||||
@@ -850,9 +968,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if iconPickerNotebook}
|
||||
<div class="icon-picker-overlay" class:mobile={isMobile}>
|
||||
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
|
||||
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
|
||||
<header class="icon-picker-header">
|
||||
<div>
|
||||
<h3>Notebook icon</h3>
|
||||
<p>{iconPickerNotebook.name}</p>
|
||||
</div>
|
||||
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="icon-picker-grid">
|
||||
{#each NOTEBOOK_ICON_OPTIONS as option}
|
||||
<button
|
||||
class="icon-picker-option"
|
||||
class:active={$notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)] === encodeBuiltinNotebookIcon(option.id)}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
|
||||
>
|
||||
<NotebookGlyph icon={option.id} size={20} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="icon-picker-actions">
|
||||
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
|
||||
Custom image...
|
||||
</button>
|
||||
{#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
|
||||
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
|
||||
Use default
|
||||
</button>
|
||||
{/if}
|
||||
<p class="icon-picker-recommendation">Recommended size: <strong>1:1 aspect ratio</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if trashContextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button class="danger" onclick={handleEmptyTrash}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
|
||||
@@ -866,7 +1031,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||
<div class="delete-confirm-actions">
|
||||
@@ -881,8 +1046,9 @@
|
||||
{@const hasChildren = nb.children.length > 0}
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
|
||||
{#if editingNotebook === nb.path}
|
||||
<div class="notebook-item" style="padding-left: {8 + depth * 16}px">
|
||||
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
@@ -896,14 +1062,15 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notebook-row" data-nb-path={nb.path}>
|
||||
<button
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||
style="padding-left: {8 + depth * 16}px"
|
||||
draggable="true"
|
||||
style="padding-left: {4 + depth * 16}px"
|
||||
data-nb-path={nb.path}
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
@@ -912,8 +1079,6 @@
|
||||
}
|
||||
}}
|
||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
||||
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
|
||||
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedNotebookPath) {
|
||||
@@ -970,15 +1135,46 @@
|
||||
{:else}
|
||||
<span class="chevron-spacer"></span>
|
||||
{/if}
|
||||
{#if iconSrc}
|
||||
{#if builtinIcon}
|
||||
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
|
||||
{:else if iconSrc}
|
||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||
{:else if isCollapsed || nb.children.length === 0}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.6">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||
{#if $notebookSortMode === 'manual'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<span
|
||||
class="nb-drag-handle"
|
||||
title="Drag to reorder"
|
||||
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
type="button"
|
||||
class="notebook-actions-btn"
|
||||
aria-label={`Actions for ${nb.name}`}
|
||||
onclick={(e) => openNotebookMenu(e, nb)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasChildren && !isCollapsed}
|
||||
{#each nb.children as child (child.path)}
|
||||
@@ -1070,7 +1266,7 @@
|
||||
.section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
padding: 0 0 4px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
@@ -1221,12 +1417,16 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.notebook-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notebook-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
padding: 5px 8px 5px 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
@@ -1293,6 +1493,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-builtin-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
@@ -1307,6 +1516,49 @@
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.nb-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.45;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nb-drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nb-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.notebook-actions-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notebook-actions-btn:active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -1375,6 +1627,15 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1402,6 +1663,195 @@
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.icon-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.icon-picker-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(8, 10, 18, 0.42);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-picker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(360px, 100%);
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.icon-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-header h3,
|
||||
.icon-picker-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-picker-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.icon-picker-header p {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-picker-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.icon-picker-option {
|
||||
min-width: 0;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 7px 3px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-option span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-picker-option:hover {
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-option.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-option.active span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-recommendation {
|
||||
flex: 0 0 100%;
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-picker-actions button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-actions button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-actions button.remove {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile {
|
||||
place-items: end center;
|
||||
padding:
|
||||
20px
|
||||
calc(12px + env(safe-area-inset-right, 0px))
|
||||
calc(12px + env(safe-area-inset-bottom, 0px))
|
||||
calc(12px + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker {
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-option {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-actions button {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1529,7 +1979,7 @@
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-item {
|
||||
padding: 12px 12px;
|
||||
padding: 2px 56px 2px 12px;
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
@@ -1540,6 +1990,11 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-builtin-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .new-notebook-input {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
@@ -1570,34 +2025,40 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu {
|
||||
min-width: 200px;
|
||||
.context-menu.mobile {
|
||||
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
|
||||
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||
top: auto !important;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
min-width: 0;
|
||||
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm {
|
||||
.delete-confirm.mobile {
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm h4 {
|
||||
.delete-confirm.mobile h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm p {
|
||||
.delete-confirm.mobile p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm-cancel,
|
||||
.sidebar.mobile .delete-confirm-btn {
|
||||
.delete-confirm.mobile .delete-confirm-cancel,
|
||||
.delete-confirm.mobile .delete-confirm-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const taken = new Set(existing);
|
||||
const names = $tags
|
||||
.map(([name]) => name)
|
||||
.filter((name) => !taken.has(name) && name.toLowerCase().includes(q));
|
||||
.filter((name) => !taken.has(name) && (!q || name.toLowerCase().includes(q)));
|
||||
names.sort((a, b) => {
|
||||
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
||||
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
||||
|
||||