Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d | ||
|
|
93122ade7b | ||
|
|
80bdabaf1b | ||
|
|
cea98cffde | ||
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef |
@@ -1,7 +1,7 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||||
[](https://helixnotes.com)
|
[](https://helixnotes.com)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.2.9)
|
## Download (v1.3.3)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -26,6 +26,12 @@ yay -S helixnotes-appimage-bin
|
|||||||
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
|
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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo eopkg it helixnotes
|
||||||
|
```
|
||||||
|
|
||||||
#### NixOS
|
#### NixOS
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -36,7 +42,7 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
helix-notes = {
|
helix-notes = {
|
||||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
# inputs.nixpkgs.follows = "nixpkgs";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -86,44 +92,56 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
|
|||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.2.9/HelixNotes_1.2.9_amd64.AppImage)
|
[Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage)
|
||||||
|
|
||||||
#### .deb (manual)
|
#### .deb (manual)
|
||||||
|
|
||||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
|
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64-setup.exe) (Windows 10/11)
|
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
[Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs)
|
||||||
|
|
||||||
|
[Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
|
||||||
|
|
||||||
|
> **"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
|
### Android
|
||||||
|
|
||||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_android.apk)
|
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_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
|
## Features
|
||||||
|
|
||||||
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
||||||
|
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
|
||||||
- `[[Wiki-links]]` and graph view
|
- `[[Wiki-links]]` and graph view
|
||||||
- Full-text search (Tantivy)
|
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
|
||||||
- Outline panel, daily notes with calendar view, tags, drag-and-drop
|
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
|
||||||
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
||||||
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
||||||
|
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
|
||||||
|
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
|
||||||
- Manual notebook sorting (drag to reorder above, into, or below)
|
- Manual notebook sorting (drag to reorder above, into, or below)
|
||||||
- External `.md` viewer mode with import-to-vault flow
|
- External `.md` viewer mode with import-to-vault flow
|
||||||
- PDF preview, Obsidian import, "Show in File Manager"
|
- PDF preview, Obsidian import, "Show in File Manager"
|
||||||
- AI writing tools (Ollama / Anthropic / OpenAI)
|
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
|
||||||
|
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
|
||||||
- Version history with diffs, automatic backups
|
- Version history with diffs, automatic backups
|
||||||
- Multi-window, file associations, focus mode, view mode
|
- Multi-window, file associations, focus mode, view mode
|
||||||
- Themes, accent colors, fonts
|
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
|
||||||
- Local files, no cloud
|
- Local plain-text files, no company cloud
|
||||||
|
|
||||||
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||||
|
|
||||||
@@ -158,10 +176,11 @@ pnpm tauri build
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## License
|
## 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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777268161,
|
"lastModified": 1782959384,
|
||||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,71 +6,77 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
self,
|
{
|
||||||
nixpkgs,
|
self,
|
||||||
flake-utils,
|
nixpkgs,
|
||||||
}:
|
flake-utils,
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
}:
|
||||||
pkgs = import nixpkgs {inherit system;};
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||||
pname = cargoToml.package.name;
|
pname = cargoToml.package.name;
|
||||||
version = cargoToml.package.version;
|
version = cargoToml.package.version;
|
||||||
in {
|
in
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
{
|
||||||
inherit pname version;
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
|
||||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||||
cargo-tauri.hook
|
|
||||||
|
|
||||||
pnpmConfigHook
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pnpm
|
inherit pname version;
|
||||||
nodejs
|
src = ./.;
|
||||||
|
fetcherVersion = 3;
|
||||||
|
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||||
|
};
|
||||||
|
|
||||||
pkg-config
|
nativeBuildInputs = with pkgs; [
|
||||||
jq
|
cargo-tauri.hook
|
||||||
moreutils
|
|
||||||
wrapGAppsHook3
|
|
||||||
];
|
|
||||||
|
|
||||||
buildInputs = with pkgs;
|
pnpmConfigHook
|
||||||
lib.optionals stdenv.hostPlatform.isLinux [
|
pnpm
|
||||||
webkitgtk_4_1
|
nodejs
|
||||||
libayatana-appindicator
|
|
||||||
|
pkg-config
|
||||||
|
jq
|
||||||
|
moreutils
|
||||||
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
cargoRoot = "src-tauri";
|
buildInputs =
|
||||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
with pkgs;
|
||||||
|
lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
webkitgtk_4_1
|
||||||
|
libayatana-appindicator
|
||||||
|
];
|
||||||
|
|
||||||
# Deactivate the upstream update mechanism
|
cargoRoot = "src-tauri";
|
||||||
postPatch = ''
|
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||||
jq '
|
|
||||||
.bundle.createUpdaterArtifacts = false |
|
|
||||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
|
||||||
' \
|
|
||||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
# Deactivate the upstream update mechanism
|
||||||
description = "HelixNotes desktop application";
|
postPatch = ''
|
||||||
homepage = "https://helixnotes.com/";
|
jq '
|
||||||
license = licenses.agpl3Plus;
|
.bundle.createUpdaterArtifacts = false |
|
||||||
platforms = platforms.linux ++ platforms.darwin;
|
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||||
mainProgram = "helixnotes";
|
' \
|
||||||
|
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "HelixNotes desktop application";
|
||||||
|
homepage = "https://helixnotes.com/";
|
||||||
|
license = licenses.agpl3Plus;
|
||||||
|
platforms = platforms.linux ++ platforms.darwin;
|
||||||
|
mainProgram = "helixnotes";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
formatter = pkgs.alejandra;
|
formatter = pkgs.nixfmt;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.0",
|
"version": "1.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"@types/katex": "^0.16.8",
|
"@types/katex": "^0.16.8",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ importers:
|
|||||||
'@types/markdown-it':
|
'@types/markdown-it':
|
||||||
specifier: ^14.1.2
|
specifier: ^14.1.2
|
||||||
version: 14.1.2
|
version: 14.1.2
|
||||||
|
highlight.js:
|
||||||
|
specifier: ^11.11.1
|
||||||
|
version: 11.11.1
|
||||||
katex:
|
katex:
|
||||||
specifier: ^0.16.28
|
specifier: ^0.16.28
|
||||||
version: 0.16.28
|
version: 0.16.28
|
||||||
|
|||||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.0"
|
version = "1.3.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.0"
|
version = "1.3.3"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -26,6 +26,13 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onWebViewCreate(webView)
|
super.onWebViewCreate(webView)
|
||||||
this.webView = webView
|
this.webView = webView
|
||||||
webView.addJavascriptInterface(StorageBridge(this), "Android")
|
webView.addJavascriptInterface(StorageBridge(this), "Android")
|
||||||
|
// Render text at the size CSS specifies, ignoring the device's system font-size
|
||||||
|
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
|
||||||
|
webView.settings.textZoom = 100
|
||||||
|
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
|
||||||
|
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
|
||||||
|
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
|
||||||
|
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
|
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: 3.0 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: 2.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: 4.0 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: 5.9 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: 8.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
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 |
|
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
|
||||||
|
}
|
||||||
@@ -55,8 +55,8 @@ pub fn ai_request(
|
|||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
Err("No base URL configured for OpenAI Compatible provider".to_string())
|
Err("No base URL configured for OpenAI Compatible provider".to_string())
|
||||||
} else {
|
} else {
|
||||||
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
|
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||||
stream_completions(
|
stream_openai(
|
||||||
&app,
|
&app,
|
||||||
&url,
|
&url,
|
||||||
key_opt,
|
key_opt,
|
||||||
@@ -94,6 +94,13 @@ pub fn ai_request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
|
||||||
|
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||||
|
fn normalize_openai_base(base: &str) -> String {
|
||||||
|
let b = base.trim().trim_end_matches('/');
|
||||||
|
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
async fn stream_anthropic(
|
async fn stream_anthropic(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -386,8 +393,8 @@ pub async fn test_connection(
|
|||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err("No base URL configured for OpenAI Compatible provider".to_string());
|
return Err("No base URL configured for OpenAI Compatible provider".to_string());
|
||||||
}
|
}
|
||||||
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
|
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||||
test_completions(&url, key_opt, model).await
|
test_openai(&url, key_opt, model).await
|
||||||
}
|
}
|
||||||
_ => test_anthropic(api_key, model).await,
|
_ => test_anthropic(api_key, model).await,
|
||||||
}
|
}
|
||||||
@@ -398,7 +405,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
"max_tokens": 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -426,174 +433,15 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_completions(
|
|
||||||
app: &AppHandle,
|
|
||||||
url: &str,
|
|
||||||
api_key: Option<&str>,
|
|
||||||
model: &str,
|
|
||||||
system_prompt: &str,
|
|
||||||
user_message: &str,
|
|
||||||
_request_id: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
|
|
||||||
|
|
||||||
let body = json!({
|
|
||||||
"model": model,
|
|
||||||
"prompt": prompt,
|
|
||||||
"max_tokens": 4096,
|
|
||||||
"stream": true,
|
|
||||||
"temperature": 0.7
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut req = client
|
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = req
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Request failed: {}", e))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(format!("API error {}: {}", status, body_text));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse SSE stream
|
|
||||||
use futures::StreamExt;
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
|
|
||||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
|
||||||
|
|
||||||
// Process complete SSE events from buffer
|
|
||||||
while let Some(event_end) = buffer.find("\n\n") {
|
|
||||||
let event_str = buffer[..event_end].to_string();
|
|
||||||
buffer = buffer[event_end + 2..].to_string();
|
|
||||||
|
|
||||||
for line in event_str.lines() {
|
|
||||||
if let Some(data) = line.strip_prefix("data: ") {
|
|
||||||
if data == "[DONE]" {
|
|
||||||
let _ = app.emit(
|
|
||||||
"ai-stream",
|
|
||||||
AiStreamEvent {
|
|
||||||
event_type: "done".to_string(),
|
|
||||||
text: None,
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
|
||||||
// Completions streaming: choices[0].text
|
|
||||||
if let Some(text) = parsed["choices"][0]["text"].as_str() {
|
|
||||||
if !text.is_empty() {
|
|
||||||
let _ = app.emit(
|
|
||||||
"ai-stream",
|
|
||||||
AiStreamEvent {
|
|
||||||
event_type: "text".to_string(),
|
|
||||||
text: Some(text.to_string()),
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check finish_reason
|
|
||||||
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
|
|
||||||
if reason == "stop" || reason == "length" {
|
|
||||||
let _ = app.emit(
|
|
||||||
"ai-stream",
|
|
||||||
AiStreamEvent {
|
|
||||||
event_type: "done".to_string(),
|
|
||||||
text: None,
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error in stream
|
|
||||||
if let Some(err) = parsed["error"]["message"].as_str() {
|
|
||||||
let _ = app.emit(
|
|
||||||
"ai-stream",
|
|
||||||
AiStreamEvent {
|
|
||||||
event_type: "error".to_string(),
|
|
||||||
text: None,
|
|
||||||
error: Some(err.to_string()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(err.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = app.emit(
|
|
||||||
"ai-stream",
|
|
||||||
AiStreamEvent {
|
|
||||||
event_type: "done".to_string(),
|
|
||||||
text: None,
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
let body = json!({
|
|
||||||
"model": model,
|
|
||||||
"prompt": "Hi",
|
|
||||||
"max_tokens": 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut req = client
|
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = req
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
Ok("Connection successful".to_string())
|
|
||||||
} else {
|
|
||||||
let status = response.status();
|
|
||||||
let body_text = response.text().await.unwrap_or_default();
|
|
||||||
Err(format!("API error {}: {}", status, body_text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_openai(url: &str, api_key: Option<&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 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!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
token_key: 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "Hi"
|
"content": "Hi"
|
||||||
|
|||||||
@@ -38,7 +38,21 @@ pub fn run() {
|
|||||||
let close_to_tray = config.close_to_tray && show_tray;
|
let close_to_tray = config.close_to_tray && show_tray;
|
||||||
let app_state = AppState::new(config);
|
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()
|
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_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
@@ -71,6 +85,13 @@ pub fn run() {
|
|||||||
setup_tray(app)?;
|
setup_tray(app)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
fix_macos_traffic_lights(&window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check CLI args for a .md file path on initial launch
|
// Check CLI args for a .md file path on initial launch
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -98,13 +119,19 @@ pub fn run() {
|
|||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::open_vault,
|
commands::open_vault,
|
||||||
|
commands::remove_vault,
|
||||||
commands::get_app_config,
|
commands::get_app_config,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
commands::set_accent_color,
|
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_size,
|
||||||
commands::set_font_family,
|
commands::set_font_family,
|
||||||
commands::set_line_height,
|
commands::set_line_height,
|
||||||
commands::set_ui_scale,
|
commands::set_ui_scale,
|
||||||
|
commands::set_content_width,
|
||||||
commands::get_notebooks,
|
commands::get_notebooks,
|
||||||
commands::count_root_notes,
|
commands::count_root_notes,
|
||||||
commands::create_notebook,
|
commands::create_notebook,
|
||||||
@@ -147,6 +174,8 @@ pub fn run() {
|
|||||||
commands::remove_quick_access,
|
commands::remove_quick_access,
|
||||||
commands::reorder_quick_access,
|
commands::reorder_quick_access,
|
||||||
commands::get_vault_stats,
|
commands::get_vault_stats,
|
||||||
|
commands::find_orphaned_attachments,
|
||||||
|
commands::trash_orphaned_attachments,
|
||||||
commands::import_obsidian,
|
commands::import_obsidian,
|
||||||
commands::open_file,
|
commands::open_file,
|
||||||
commands::open_url,
|
commands::open_url,
|
||||||
@@ -168,6 +197,7 @@ pub fn run() {
|
|||||||
commands::test_sync_connection,
|
commands::test_sync_connection,
|
||||||
commands::sync_now,
|
commands::sync_now,
|
||||||
commands::get_install_type,
|
commands::get_install_type,
|
||||||
|
commands::is_mobile_platform,
|
||||||
commands::get_pending_open_file,
|
commands::get_pending_open_file,
|
||||||
])
|
])
|
||||||
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
|
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
|
||||||
@@ -364,3 +394,46 @@ fn percent_decode(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
String::from_utf8_lossy(&output).to_string()
|
String::from_utf8_lossy(&output).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn fix_macos_traffic_lights(window: &tauri::WebviewWindow) {
|
||||||
|
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||||
|
|
||||||
|
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(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let superview = match close.superview() {
|
||||||
|
Some(sv) => sv,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sv_height = NSView::frame(&superview).size.height;
|
||||||
|
let btn_height = NSView::frame(&close).size.height;
|
||||||
|
if sv_height <= 0.0 || btn_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let centered_y = (sv_height - btn_height) / 2.0;
|
||||||
|
|
||||||
|
for btn in [&close, &miniaturize, &zoom] {
|
||||||
|
let mut frame = NSView::frame(btn);
|
||||||
|
frame.origin.y = centered_y;
|
||||||
|
btn.setFrameOrigin(frame.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let s = resp.status().as_u16();
|
let s = resp.status().as_u16();
|
||||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||||
if resp.status().is_success() || s == 405 || s == 301 {
|
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||||
|
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||||
@@ -339,31 +340,72 @@ impl WebdavClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put_with_dirs(
|
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||||
|
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||||
|
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||||
|
/// to keep it consistent with future listings. (issue #147)
|
||||||
|
fn etag_for(&self, relpath: &str) -> String {
|
||||||
|
self.propfind(relpath, "0")
|
||||||
|
.ok()
|
||||||
|
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||||
|
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||||
|
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||||
|
fn upload(
|
||||||
&self,
|
&self,
|
||||||
relpath: &str,
|
relpath: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
created: &mut HashSet<String>,
|
created: &mut HashSet<String>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<String, String> {
|
||||||
self.ensure_dirs(relpath, created)?;
|
self.ensure_dirs(relpath, created)?;
|
||||||
self.put(relpath, bytes)
|
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||||
|
Ok(self.etag_for(relpath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
|
/// Map a WebDAV `href` to a vault-relative path (forward slash).
|
||||||
/// the base collection itself.
|
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||||
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
|
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||||
let path = if href.starts_with("http://") || href.starts_with("https://") {
|
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||||
reqwest::Url::parse(href).ok()?.path().to_string()
|
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||||
|
/// wipes the local copy. (issue #102)
|
||||||
|
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||||
|
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||||
|
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||||
|
reqwest::Url::parse(href)
|
||||||
|
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||||
|
.path()
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
href.to_string()
|
href.to_string()
|
||||||
};
|
};
|
||||||
let path = path.trim_end_matches('/');
|
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||||
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
|
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||||
|
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||||
|
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||||
|
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||||
|
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||||
|
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||||
|
.into_owned();
|
||||||
|
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||||
|
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||||
|
.into_owned();
|
||||||
|
let rest = match path.strip_prefix(&base) {
|
||||||
|
Some(r) => r.trim_start_matches('/'),
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||||
|
Aborting before deleting anything locally - please report this URL."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return None;
|
return Ok(None); // the base collection itself
|
||||||
}
|
}
|
||||||
Some(urlencoding::decode(rest).ok()?.into_owned())
|
Ok(Some(rest.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||||
@@ -427,7 +469,7 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
|||||||
}
|
}
|
||||||
b"response" => {
|
b"response" => {
|
||||||
if let Some(h) = href.take() {
|
if let Some(h) = href.take() {
|
||||||
if let Some(rel) = href_to_relpath(&h, base_path) {
|
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||||
entries.push(RemoteEntry {
|
entries.push(RemoteEntry {
|
||||||
relpath: rel,
|
relpath: rel,
|
||||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||||
@@ -485,12 +527,12 @@ fn apply_changes(
|
|||||||
);
|
);
|
||||||
} else if local_changed && !remote_changed {
|
} else if local_changed && !remote_changed {
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_else(|| re.clone()),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -530,12 +572,12 @@ fn apply_changes(
|
|||||||
);
|
);
|
||||||
let ckey = conflict_relpath(key, &stamp);
|
let ckey = conflict_relpath(key, &stamp);
|
||||||
write_local(vault_path, &ckey, &lbytes)?;
|
write_local(vault_path, &ckey, &lbytes)?;
|
||||||
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
|
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
ckey,
|
ckey,
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: sha256_hex(&lbytes),
|
local_hash: sha256_hex(&lbytes),
|
||||||
remote_etag: cetag.unwrap_or_default(),
|
remote_etag: cetag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.conflicts += 1;
|
sum.conflicts += 1;
|
||||||
@@ -546,12 +588,12 @@ fn apply_changes(
|
|||||||
None => {
|
None => {
|
||||||
// new local -> upload
|
// new local -> upload
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_default(),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -560,12 +602,12 @@ fn apply_changes(
|
|||||||
if me.local_hash != lf.hash {
|
if me.local_hash != lf.hash {
|
||||||
// remote deleted but local edited -> resurrect (upload)
|
// remote deleted but local edited -> resurrect (upload)
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_default(),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -642,6 +684,20 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
|||||||
let local = scan_local(&vault)?;
|
let local = scan_local(&vault)?;
|
||||||
let manifest = load_manifest(&vault);
|
let manifest = load_manifest(&vault);
|
||||||
|
|
||||||
|
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||||
|
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||||
|
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||||
|
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||||
|
if remote.is_empty() && !manifest.files.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||||
|
even though files were synced before, so no local files were deleted. This usually \
|
||||||
|
means the server reports paths in a format the app can't read yet - please report \
|
||||||
|
your WebDAV provider and folder URL."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||||
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||||
state.importing.store(true, Ordering::Relaxed);
|
state.importing.store(true, Ordering::Relaxed);
|
||||||
|
|||||||
@@ -50,10 +50,48 @@ pub struct NoteContent {
|
|||||||
pub raw: String,
|
pub raw: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct VaultConfig {
|
pub struct VaultConfig {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub name: String,
|
pub name: 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, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -72,6 +110,8 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ui_scale: Option<f64>,
|
pub ui_scale: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub content_width: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
pub compact_notes: bool,
|
pub compact_notes: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_note_dates: bool,
|
pub show_note_dates: bool,
|
||||||
@@ -79,6 +119,8 @@ pub struct AppConfig {
|
|||||||
pub time_format: String,
|
pub time_format: String,
|
||||||
#[serde(default = "default_week_start")]
|
#[serde(default = "default_week_start")]
|
||||||
pub week_start: String,
|
pub week_start: String,
|
||||||
|
#[serde(default = "default_daily_title_format")]
|
||||||
|
pub daily_title_format: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gpu_acceleration: bool,
|
pub gpu_acceleration: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -93,6 +135,18 @@ pub struct AppConfig {
|
|||||||
pub hide_title_in_body: bool,
|
pub hide_title_in_body: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub show_line_numbers: bool,
|
pub show_line_numbers: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_link_arrows: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_all_notes: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_quick_access: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_tasks: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_daily_notes: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_trash: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub backup_enabled: bool,
|
pub backup_enabled: bool,
|
||||||
#[serde(default = "default_backup_frequency")]
|
#[serde(default = "default_backup_frequency")]
|
||||||
@@ -135,7 +189,7 @@ pub struct AppConfig {
|
|||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub restore_last_session: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub sync_provider: Option<String>,
|
pub sync_provider: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -152,12 +206,18 @@ pub struct AppConfig {
|
|||||||
pub sync_interval_minutes: u32,
|
pub sync_interval_minutes: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync_time: Option<String>,
|
pub last_sync_time: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_themes: Vec<CustomTheme>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_outline_width() -> f64 {
|
||||||
|
220.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_pdf_height() -> u32 {
|
fn default_pdf_height() -> u32 {
|
||||||
600
|
600
|
||||||
}
|
}
|
||||||
@@ -178,6 +238,10 @@ fn default_week_start() -> String {
|
|||||||
"monday".to_string()
|
"monday".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_daily_title_format() -> String {
|
||||||
|
"localized".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_max_versions() -> u32 {
|
fn default_max_versions() -> u32 {
|
||||||
20
|
20
|
||||||
}
|
}
|
||||||
@@ -197,10 +261,12 @@ impl Default for AppConfig {
|
|||||||
font_family: None,
|
font_family: None,
|
||||||
line_height: None,
|
line_height: None,
|
||||||
ui_scale: None,
|
ui_scale: None,
|
||||||
|
content_width: None,
|
||||||
compact_notes: false,
|
compact_notes: false,
|
||||||
show_note_dates: true,
|
show_note_dates: true,
|
||||||
time_format: "relative".to_string(),
|
time_format: "relative".to_string(),
|
||||||
week_start: "monday".to_string(),
|
week_start: "monday".to_string(),
|
||||||
|
daily_title_format: "localized".to_string(),
|
||||||
gpu_acceleration: true,
|
gpu_acceleration: true,
|
||||||
autostart: false,
|
autostart: false,
|
||||||
pdf_preview: false,
|
pdf_preview: false,
|
||||||
@@ -208,6 +274,12 @@ impl Default for AppConfig {
|
|||||||
title_mode: "input".to_string(),
|
title_mode: "input".to_string(),
|
||||||
hide_title_in_body: false,
|
hide_title_in_body: false,
|
||||||
show_line_numbers: false,
|
show_line_numbers: false,
|
||||||
|
show_link_arrows: true,
|
||||||
|
show_all_notes: true,
|
||||||
|
show_quick_access: true,
|
||||||
|
show_tasks: true,
|
||||||
|
show_daily_notes: true,
|
||||||
|
show_trash: true,
|
||||||
backup_enabled: false,
|
backup_enabled: false,
|
||||||
backup_frequency: "24h".to_string(),
|
backup_frequency: "24h".to_string(),
|
||||||
backup_max_count: 10,
|
backup_max_count: 10,
|
||||||
@@ -237,6 +309,7 @@ impl Default for AppConfig {
|
|||||||
sync_on_change: false,
|
sync_on_change: false,
|
||||||
sync_interval_minutes: 0,
|
sync_interval_minutes: 0,
|
||||||
last_sync_time: None,
|
last_sync_time: None,
|
||||||
|
custom_themes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,14 +319,20 @@ pub struct VaultState {
|
|||||||
pub last_open_note: Option<String>,
|
pub last_open_note: Option<String>,
|
||||||
pub sidebar_width: f64,
|
pub sidebar_width: f64,
|
||||||
pub notelist_width: f64,
|
pub notelist_width: f64,
|
||||||
|
#[serde(default = "default_outline_width")]
|
||||||
|
pub outline_width: f64,
|
||||||
pub sidebar_collapsed: bool,
|
pub sidebar_collapsed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub notelist_collapsed: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub collapsed_notebooks: Vec<String>,
|
pub collapsed_notebooks: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notebook_sort_mode: String,
|
pub notebook_sort_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub note_order: std::collections::HashMap<String, i32>,
|
||||||
|
#[serde(default)]
|
||||||
pub sort_mode: String,
|
pub sort_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_view_mode: String,
|
pub last_view_mode: String,
|
||||||
@@ -263,6 +342,12 @@ pub struct VaultState {
|
|||||||
pub last_tag: Option<String>,
|
pub last_tag: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tasks_layout: String,
|
pub tasks_layout: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub tasks_hide_completed: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tasks_only_flagged: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tasks_sort: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for VaultState {
|
impl Default for VaultState {
|
||||||
@@ -271,15 +356,21 @@ impl Default for VaultState {
|
|||||||
last_open_note: None,
|
last_open_note: None,
|
||||||
sidebar_width: 220.0,
|
sidebar_width: 220.0,
|
||||||
notelist_width: 280.0,
|
notelist_width: 280.0,
|
||||||
|
outline_width: default_outline_width(),
|
||||||
sidebar_collapsed: false,
|
sidebar_collapsed: false,
|
||||||
|
notelist_collapsed: false,
|
||||||
collapsed_notebooks: Vec::new(),
|
collapsed_notebooks: Vec::new(),
|
||||||
notebook_sort_mode: String::new(),
|
notebook_sort_mode: String::new(),
|
||||||
notebook_order: std::collections::HashMap::new(),
|
notebook_order: std::collections::HashMap::new(),
|
||||||
|
note_order: std::collections::HashMap::new(),
|
||||||
sort_mode: String::new(),
|
sort_mode: String::new(),
|
||||||
last_view_mode: String::new(),
|
last_view_mode: String::new(),
|
||||||
last_notebook: None,
|
last_notebook: None,
|
||||||
last_tag: None,
|
last_tag: None,
|
||||||
tasks_layout: String::new(),
|
tasks_layout: String::new(),
|
||||||
|
tasks_hide_completed: true,
|
||||||
|
tasks_only_flagged: false,
|
||||||
|
tasks_sort: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,6 +393,9 @@ pub struct FileEvent {
|
|||||||
pub struct ImportResult {
|
pub struct ImportResult {
|
||||||
pub files_converted: u64,
|
pub files_converted: u64,
|
||||||
pub links_converted: u64,
|
pub links_converted: u64,
|
||||||
|
pub frontmatter_normalized: u64,
|
||||||
|
pub syntax_converted: u64,
|
||||||
|
pub attachments_moved: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct RawFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Try to parse a date string in multiple common formats.
|
/// 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();
|
let s = s.trim();
|
||||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
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)
|
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");
|
let stem = filename.trim_end_matches(".md");
|
||||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod frontmatter;
|
pub mod frontmatter;
|
||||||
|
pub mod import;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
|||||||
@@ -52,11 +52,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||||
let mut entries = Vec::new();
|
|
||||||
let root = Path::new(vault_root);
|
let root = Path::new(vault_root);
|
||||||
|
|
||||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
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)
|
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
||||||
@@ -76,29 +75,29 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
|||||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store note_count for the parent directory (used when this is the top-level call)
|
// Scan sibling notebooks in parallel: each subtree is independent, so
|
||||||
// Each directory pushes its own entry with its own note_count
|
// overlapping the per-directory read_dir calls hides FUSE latency on Android.
|
||||||
for dir_entry in dirs {
|
// par_iter().collect() preserves the (already-sorted) order.
|
||||||
let path = dir_entry.path();
|
let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
|
||||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
paths
|
||||||
let relative = path
|
.par_iter()
|
||||||
.strip_prefix(root)
|
.map(|path| {
|
||||||
.unwrap_or(&path)
|
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
.to_string_lossy()
|
let relative = path
|
||||||
.to_string();
|
.strip_prefix(root)
|
||||||
|
.unwrap_or(path)
|
||||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
entries.push(NotebookEntry {
|
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||||
name,
|
NotebookEntry {
|
||||||
path: path.to_string_lossy().to_string(),
|
name,
|
||||||
relative_path: relative,
|
path: path.to_string_lossy().to_string(),
|
||||||
children,
|
relative_path: relative,
|
||||||
note_count: child_note_count,
|
children,
|
||||||
});
|
note_count: child_note_count,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
entries
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
||||||
@@ -130,26 +129,26 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
|||||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
|
||||||
for dir_entry in subdirs {
|
let entries: Vec<NotebookEntry> = paths
|
||||||
let path = dir_entry.path();
|
.par_iter()
|
||||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
.map(|path| {
|
||||||
let relative = path
|
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
.strip_prefix(root)
|
let relative = path
|
||||||
.unwrap_or(&path)
|
.strip_prefix(root)
|
||||||
.to_string_lossy()
|
.unwrap_or(path)
|
||||||
.to_string();
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||||
|
NotebookEntry {
|
||||||
entries.push(NotebookEntry {
|
name,
|
||||||
name,
|
path: path.to_string_lossy().to_string(),
|
||||||
path: path.to_string_lossy().to_string(),
|
relative_path: relative,
|
||||||
relative_path: relative,
|
children,
|
||||||
children,
|
note_count: child_note_count,
|
||||||
note_count: child_note_count,
|
}
|
||||||
});
|
})
|
||||||
}
|
.collect();
|
||||||
|
|
||||||
(entries, note_count)
|
(entries, note_count)
|
||||||
}
|
}
|
||||||
@@ -197,47 +196,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
|
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
{
|
{
|
||||||
let mut notes = Vec::new();
|
// Collect candidate .md paths first, then read their metadata in parallel.
|
||||||
// Log what read_dir sees
|
// Overlapping the per-file reads hides FUSE latency on Android (matches the
|
||||||
if let Ok(rd) = fs::read_dir(root) {
|
// desktop path below). The previous version also did an extra read_dir purely
|
||||||
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
// for debug logging, which doubled the directory I/O on the sandboxed FS.
|
||||||
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
|
let paths: Vec<PathBuf> = if notebook_path.is_some() {
|
||||||
for (i, entry) in items.iter().enumerate().take(5) {
|
match fs::read_dir(root) {
|
||||||
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
|
Ok(rd) => rd
|
||||||
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
|
.filter_map(|e| e.ok())
|
||||||
}
|
.map(|e| e.path())
|
||||||
} else {
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
log::info!("scan_notes: read_dir FAILED for {:?}", root);
|
.collect(),
|
||||||
}
|
Err(_) => Vec::new(),
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let hn_dir = helixnotes_dir(vault_path);
|
let hn_dir = helixnotes_dir(vault_path);
|
||||||
for entry in WalkDir::new(root)
|
WalkDir::new(root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
// filter_entry skips descending into hidden dirs (unlike filter)
|
// filter_entry skips descending into hidden dirs (unlike filter)
|
||||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| {
|
.map(|e| e.path().to_path_buf())
|
||||||
e.file_type().is_file()
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
.collect()
|
||||||
})
|
};
|
||||||
{
|
|
||||||
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
|
let mut notes: Vec<NoteEntry> = paths
|
||||||
notes.push(note);
|
.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());
|
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||||
return Ok(notes);
|
return Ok(notes);
|
||||||
@@ -386,7 +374,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||||
|
|
||||||
|
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
|
||||||
|
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
|
||||||
|
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
|
||||||
|
// resetting the create date of imported/frontmatter-less notes. (issue #139)
|
||||||
|
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
|
||||||
|
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
|
||||||
|
if let Ok(fs_meta) = fs::metadata(p) {
|
||||||
|
if !has_modified {
|
||||||
|
if let Ok(modified) = fs_meta.modified() {
|
||||||
|
meta.modified = modified.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_created {
|
||||||
|
if let Ok(created) = fs_meta.created() {
|
||||||
|
meta.created = created.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(NoteContent {
|
Ok(NoteContent {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
@@ -520,15 +527,27 @@ fn get_system_locale() -> Locale {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
|
pub fn create_daily_note(
|
||||||
|
vault_path: &str,
|
||||||
|
date: Option<&str>,
|
||||||
|
format: &str,
|
||||||
|
) -> Result<NoteEntry, String> {
|
||||||
let target_date = match date {
|
let target_date = match date {
|
||||||
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
|
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
|
||||||
.map_err(|e| format!("Invalid date: {}", e))?,
|
.map_err(|e| format!("Invalid date: {}", e))?,
|
||||||
None => Local::now().date_naive(),
|
None => Local::now().date_naive(),
|
||||||
};
|
};
|
||||||
let date_str = target_date.format("%Y-%m-%d").to_string();
|
let date_str = target_date.format("%Y-%m-%d").to_string();
|
||||||
let locale = get_system_locale();
|
let title = match format {
|
||||||
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
|
"iso" => target_date.format("%Y-%m-%d").to_string(),
|
||||||
|
"long" => target_date.format("%B %-d, %Y").to_string(),
|
||||||
|
"us" => target_date.format("%m/%d/%Y").to_string(),
|
||||||
|
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||||
|
_ => {
|
||||||
|
let locale = get_system_locale();
|
||||||
|
target_date.format_localized("%B %d, %Y", locale).to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let dir = Path::new(vault_path).join("Daily");
|
let dir = Path::new(vault_path).join("Daily");
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
};
|
};
|
||||||
let _ = app.emit("file-changed", &fe);
|
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) => {
|
Err(e) => {
|
||||||
log::error!("File watcher error: {}", e);
|
log::error!("File watcher error: {}", e);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.0",
|
"version": "1.3.3",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
"appimage": {
|
"appimage": {
|
||||||
"bundleMediaFramework": false
|
"bundleMediaFramework": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": "entitlements.plist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"trafficLightPosition": {
|
"trafficLightPosition": {
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 10
|
"y": 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
CustomTheme,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NoteEntry,
|
NoteEntry,
|
||||||
NoteMeta,
|
NoteMeta,
|
||||||
@@ -20,6 +21,10 @@ export async function openVault(path: string): Promise<void> {
|
|||||||
return invoke("open_vault", { path });
|
return invoke("open_vault", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeVault(path: string): Promise<void> {
|
||||||
|
return invoke("remove_vault", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAppConfig(): Promise<AppConfig> {
|
export async function getAppConfig(): Promise<AppConfig> {
|
||||||
return invoke("get_app_config");
|
return invoke("get_app_config");
|
||||||
}
|
}
|
||||||
@@ -32,6 +37,22 @@ export async function setAccentColor(color: string): Promise<void> {
|
|||||||
return invoke("set_accent_color", { color });
|
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> {
|
export async function setFontSize(size: number): Promise<void> {
|
||||||
return invoke("set_font_size", { size });
|
return invoke("set_font_size", { size });
|
||||||
}
|
}
|
||||||
@@ -48,6 +69,10 @@ export async function setUiScale(scale: number): Promise<void> {
|
|||||||
return invoke("set_ui_scale", { scale });
|
return invoke("set_ui_scale", { scale });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setContentWidth(width: number | null): Promise<void> {
|
||||||
|
return invoke("set_content_width", { width });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
||||||
return invoke("get_notebooks");
|
return invoke("get_notebooks");
|
||||||
}
|
}
|
||||||
@@ -216,6 +241,7 @@ export async function setGeneralSettings(
|
|||||||
compactNotes: boolean,
|
compactNotes: boolean,
|
||||||
timeFormat: string,
|
timeFormat: string,
|
||||||
weekStart: string,
|
weekStart: string,
|
||||||
|
dailyTitleFormat: string,
|
||||||
gpuAcceleration: boolean,
|
gpuAcceleration: boolean,
|
||||||
autostart: boolean,
|
autostart: boolean,
|
||||||
pdfPreview: boolean,
|
pdfPreview: boolean,
|
||||||
@@ -223,17 +249,24 @@ export async function setGeneralSettings(
|
|||||||
titleMode: string,
|
titleMode: string,
|
||||||
hideTitleInBody: boolean,
|
hideTitleInBody: boolean,
|
||||||
showLineNumbers: boolean,
|
showLineNumbers: boolean,
|
||||||
|
showLinkArrows: boolean,
|
||||||
defaultViewMode: boolean,
|
defaultViewMode: boolean,
|
||||||
showTrayIcon: boolean,
|
showTrayIcon: boolean,
|
||||||
closeToTray: boolean,
|
closeToTray: boolean,
|
||||||
enableWikiLinks: boolean,
|
enableWikiLinks: boolean,
|
||||||
showNoteDates: boolean,
|
showNoteDates: boolean,
|
||||||
restoreLastSession: boolean,
|
restoreLastSession: boolean,
|
||||||
|
showAllNotes: boolean,
|
||||||
|
showQuickAccess: boolean,
|
||||||
|
showTasks: boolean,
|
||||||
|
showDailyNotes: boolean,
|
||||||
|
showTrash: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return invoke("set_general_settings", {
|
return invoke("set_general_settings", {
|
||||||
compactNotes,
|
compactNotes,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
weekStart,
|
weekStart,
|
||||||
|
dailyTitleFormat,
|
||||||
gpuAcceleration,
|
gpuAcceleration,
|
||||||
autostart,
|
autostart,
|
||||||
pdfPreview,
|
pdfPreview,
|
||||||
@@ -241,12 +274,18 @@ export async function setGeneralSettings(
|
|||||||
titleMode,
|
titleMode,
|
||||||
hideTitleInBody,
|
hideTitleInBody,
|
||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
|
showLinkArrows,
|
||||||
defaultViewMode,
|
defaultViewMode,
|
||||||
showTrayIcon,
|
showTrayIcon,
|
||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
|
showAllNotes,
|
||||||
|
showQuickAccess,
|
||||||
|
showTasks,
|
||||||
|
showDailyNotes,
|
||||||
|
showTrash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +309,19 @@ export async function getVaultStats(): Promise<VaultStats> {
|
|||||||
return invoke("get_vault_stats");
|
return invoke("get_vault_stats");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrphanAttachment {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
|
||||||
|
return invoke("find_orphaned_attachments");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
|
||||||
|
return invoke("trash_orphaned_attachments", { names });
|
||||||
|
}
|
||||||
|
|
||||||
export async function importObsidian(): Promise<void> {
|
export async function importObsidian(): Promise<void> {
|
||||||
return invoke("import_obsidian");
|
return invoke("import_obsidian");
|
||||||
}
|
}
|
||||||
@@ -444,6 +496,10 @@ export async function getInstallType(): Promise<string> {
|
|||||||
return invoke("get_install_type");
|
return invoke("get_install_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isMobilePlatform(): Promise<boolean> {
|
||||||
|
return invoke("is_mobile_platform");
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPendingOpenFile(): Promise<string | null> {
|
export async function getPendingOpenFile(): Promise<string | null> {
|
||||||
return invoke("get_pending_open_file");
|
return invoke("get_pending_open_file");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import Sidebar from './Sidebar.svelte';
|
import Sidebar from './Sidebar.svelte';
|
||||||
import NoteList from './NoteList.svelte';
|
import NoteList from './NoteList.svelte';
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
import {
|
import {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
showSearch,
|
showSearch,
|
||||||
showCommandPalette,
|
showCommandPalette,
|
||||||
@@ -37,7 +39,11 @@
|
|||||||
rootNoteCount,
|
rootNoteCount,
|
||||||
viewMode,
|
viewMode,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
tasksLayout,
|
tasksLayout,
|
||||||
|
tasksHideCompleted,
|
||||||
|
tasksOnlyFlagged,
|
||||||
|
tasksSort,
|
||||||
activeTag,
|
activeTag,
|
||||||
updateAvailable as globalUpdateAvailable,
|
updateAvailable as globalUpdateAvailable,
|
||||||
settingsTab,
|
settingsTab,
|
||||||
@@ -45,14 +51,18 @@
|
|||||||
viewerNote,
|
viewerNote,
|
||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder,
|
notebookOrder,
|
||||||
syncState
|
noteOrder,
|
||||||
|
syncState,
|
||||||
|
platformIsMobile,
|
||||||
|
activeVaultConfig,
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
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, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
import { darkThemes, isAndroid } from '$lib/platform';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -112,16 +122,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncConfigured(): boolean {
|
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).
|
// Auto-sync on a timer (only when configured and an interval is set).
|
||||||
async function checkScheduledSync() {
|
async function checkScheduledSync() {
|
||||||
const config = get(appConfig);
|
const vc = activeVaultConfig(get(appConfig));
|
||||||
if (config?.sync_provider !== 'webdav') return;
|
if (vc?.sync_provider !== 'webdav') return;
|
||||||
const mins = config.sync_interval_minutes ?? 0;
|
const mins = vc.sync_interval_minutes ?? 0;
|
||||||
if (!mins || get(syncState).running) return;
|
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) {
|
if (Date.now() - last >= mins * 60 * 1000) {
|
||||||
try { await syncNow(); } catch (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -194,15 +204,22 @@
|
|||||||
last_open_note: $activeNotePath,
|
last_open_note: $activeNotePath,
|
||||||
sidebar_width: $sidebarWidth,
|
sidebar_width: $sidebarWidth,
|
||||||
notelist_width: $notelistWidth,
|
notelist_width: $notelistWidth,
|
||||||
|
outline_width: $outlineWidth,
|
||||||
sidebar_collapsed: $sidebarCollapsed,
|
sidebar_collapsed: $sidebarCollapsed,
|
||||||
|
notelist_collapsed: $notelistCollapsed,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
notebook_sort_mode: $notebookSortMode,
|
notebook_sort_mode: $notebookSortMode,
|
||||||
notebook_order: $notebookOrder,
|
notebook_order: $notebookOrder,
|
||||||
|
note_order: $noteOrder,
|
||||||
sort_mode: $sortMode,
|
sort_mode: $sortMode,
|
||||||
|
group_notes_by_date: $groupNotesByDate,
|
||||||
last_view_mode: $viewMode,
|
last_view_mode: $viewMode,
|
||||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||||
last_tag: $activeTag,
|
last_tag: $activeTag,
|
||||||
tasks_layout: $tasksLayout
|
tasks_layout: $tasksLayout,
|
||||||
|
tasks_hide_completed: $tasksHideCompleted,
|
||||||
|
tasks_only_flagged: $tasksOnlyFlagged,
|
||||||
|
tasks_sort: $tasksSort
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await saveVaultState(state);
|
await saveVaultState(state);
|
||||||
@@ -219,20 +236,40 @@
|
|||||||
persistState();
|
persistState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealNoteList() {
|
||||||
|
$notelistCollapsed = false;
|
||||||
|
tick().then(() => noteList?.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNoteList() {
|
||||||
|
const nextCollapsed = !$notelistCollapsed;
|
||||||
|
$notelistCollapsed = nextCollapsed;
|
||||||
|
if (!nextCollapsed) {
|
||||||
|
tick().then(() => noteList?.refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||||
let taskNoteOpened = $state(false);
|
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
|
// Selecting a real vault note exits viewer mode
|
||||||
$viewerNote = null;
|
$viewerNote = null;
|
||||||
taskNoteOpened = true;
|
taskNoteOpened = true;
|
||||||
editor?.loadNote(path, content);
|
editor?.loadNote(path, content, task);
|
||||||
if (isMobile) $mobileView = 'editor';
|
if (isMobile) $mobileView = 'editor';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewChanged() {
|
function handleViewChanged() {
|
||||||
taskNoteOpened = false;
|
taskNoteOpened = false;
|
||||||
noteList?.refresh();
|
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||||
|
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
|
||||||
|
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
|
||||||
|
if ($notelistCollapsed) {
|
||||||
|
revealNoteList();
|
||||||
|
} else {
|
||||||
|
noteList?.refresh();
|
||||||
|
}
|
||||||
if (isMobile) $mobileView = 'notelist';
|
if (isMobile) $mobileView = 'notelist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +360,7 @@
|
|||||||
let historyDepth = 0;
|
let historyDepth = 0;
|
||||||
let navFromPopstate = false;
|
let navFromPopstate = false;
|
||||||
|
|
||||||
if (isMobile) {
|
if (get(platformIsMobile)) {
|
||||||
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
|
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -353,6 +390,16 @@
|
|||||||
navFromPopstate = false;
|
navFromPopstate = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If a modal is open, close it instead of navigating
|
||||||
|
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
|
||||||
|
$showSettings = false;
|
||||||
|
$showInfo = false;
|
||||||
|
$showSearch = false;
|
||||||
|
$showCommandPalette = false;
|
||||||
|
// Re-push the current state so the next back still works
|
||||||
|
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = e.state;
|
const state = e.state;
|
||||||
const targetDepth = state?.depth ?? 0;
|
const targetDepth = state?.depth ?? 0;
|
||||||
historyDepth = targetDepth;
|
historyDepth = targetDepth;
|
||||||
@@ -376,88 +423,83 @@
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const code = e.code;
|
const code = e.code;
|
||||||
if (e.altKey && e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||||
navigateHistory(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.altKey && e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateHistory(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyN') {
|
|
||||||
e.preventDefault();
|
|
||||||
createAndFocusNote();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyN') {
|
|
||||||
e.preventDefault();
|
|
||||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
||||||
const next = isDark ? 'light' : 'dark';
|
|
||||||
$theme = next;
|
|
||||||
setTheme(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyF') {
|
|
||||||
e.preventDefault();
|
|
||||||
$showSearch = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyF') {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($activeNotePath) {
|
|
||||||
editor?.openNoteSearch();
|
|
||||||
} else {
|
|
||||||
$showSearch = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyP') {
|
|
||||||
e.preventDefault();
|
|
||||||
$showCommandPalette = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyS') {
|
|
||||||
e.preventDefault();
|
|
||||||
editor?.forceSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.addLinkFromToolbar();
|
editor?.addLinkFromToolbar();
|
||||||
}
|
}
|
||||||
if (mod && e.shiftKey && code === 'KeyM') {
|
|
||||||
|
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||||
|
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
noteList?.trashActiveNote();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (mod && e.shiftKey && code === 'KeyW') {
|
|
||||||
|
const action = matchAction(e, $keybindings);
|
||||||
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if ($activeNotePath && $activeNote) {
|
switch (action) {
|
||||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
case 'nav-back':
|
||||||
|
navigateHistory(-1);
|
||||||
|
return;
|
||||||
|
case 'nav-forward':
|
||||||
|
navigateHistory(1);
|
||||||
|
return;
|
||||||
|
case 'new-note':
|
||||||
|
createAndFocusNote();
|
||||||
|
return;
|
||||||
|
case 'toggle-theme': {
|
||||||
|
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
const next = isDark ? 'light' : 'dark';
|
||||||
|
$theme = next;
|
||||||
|
setTheme(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'search-vault':
|
||||||
|
$showSearch = true;
|
||||||
|
return;
|
||||||
|
case 'find-in-note':
|
||||||
|
if ($activeNotePath) {
|
||||||
|
editor?.openNoteSearch();
|
||||||
|
} else {
|
||||||
|
$showSearch = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'quick-open':
|
||||||
|
$showCommandPalette = true;
|
||||||
|
return;
|
||||||
|
case 'save':
|
||||||
|
editor?.forceSave();
|
||||||
|
return;
|
||||||
|
case 'toggle-source':
|
||||||
|
$sourceMode = !$sourceMode;
|
||||||
|
return;
|
||||||
|
case 'open-new-window':
|
||||||
|
if ($activeNotePath && $activeNote) {
|
||||||
|
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'toggle-sidebar':
|
||||||
|
$sidebarCollapsed = !$sidebarCollapsed;
|
||||||
|
return;
|
||||||
|
case 'toggle-notelist':
|
||||||
|
toggleNoteList();
|
||||||
|
return;
|
||||||
|
case 'toggle-focus':
|
||||||
|
$focusMode = !$focusMode;
|
||||||
|
return;
|
||||||
|
case 'toggle-readonly':
|
||||||
|
if ($viewerNote) return; // viewer mode is always read-only
|
||||||
|
$readOnly = !$readOnly;
|
||||||
|
return;
|
||||||
|
case 'fullscreen':
|
||||||
|
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mod && e.key === '\\') {
|
|
||||||
e.preventDefault();
|
|
||||||
$sidebarCollapsed = !$sidebarCollapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyE') {
|
|
||||||
e.preventDefault();
|
|
||||||
$focusMode = !$focusMode;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyR') {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($viewerNote) return; // viewer mode is always read-only
|
|
||||||
$readOnly = !$readOnly;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'F11') {
|
|
||||||
e.preventDefault();
|
|
||||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
@@ -468,8 +510,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
function applyTheme(t: string) {
|
||||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
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 namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
@@ -495,15 +536,31 @@
|
|||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$sidebarCollapsed;
|
||||||
|
$notelistCollapsed;
|
||||||
|
$outlineWidth;
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$notebookSortMode;
|
$notebookSortMode;
|
||||||
$notebookOrder;
|
$notebookOrder;
|
||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$noteOrder;
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sortMode;
|
$sortMode;
|
||||||
|
$groupNotesByDate;
|
||||||
$tasksLayout;
|
$tasksLayout;
|
||||||
|
$tasksHideCompleted;
|
||||||
|
$tasksOnlyFlagged;
|
||||||
|
$tasksSort;
|
||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -523,12 +580,19 @@
|
|||||||
const state = await loadVaultState();
|
const state = await loadVaultState();
|
||||||
$sidebarWidth = state.sidebar_width;
|
$sidebarWidth = state.sidebar_width;
|
||||||
$notelistWidth = state.notelist_width;
|
$notelistWidth = state.notelist_width;
|
||||||
|
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||||
$sidebarCollapsed = state.sidebar_collapsed;
|
$sidebarCollapsed = state.sidebar_collapsed;
|
||||||
|
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||||
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
||||||
$notebookOrder = state.notebook_order ?? {};
|
$notebookOrder = state.notebook_order ?? {};
|
||||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
|
$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 (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;
|
||||||
|
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
|
||||||
lastNotePath = state.last_open_note ?? null;
|
lastNotePath = state.last_open_note ?? null;
|
||||||
lastViewMode = state.last_view_mode ?? '';
|
lastViewMode = state.last_view_mode ?? '';
|
||||||
lastNotebook = state.last_notebook ?? null;
|
lastNotebook = state.last_notebook ?? null;
|
||||||
@@ -550,6 +614,26 @@
|
|||||||
// Run sidebar and note list refresh in parallel
|
// Run sidebar and note list refresh in parallel
|
||||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||||
|
|
||||||
|
// Auto-cleanup orphaned attachments in the background
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
// Restore the last session (view + open note) if enabled.
|
// Restore the last session (view + open note) if enabled.
|
||||||
if ($appConfig?.restore_last_session) {
|
if ($appConfig?.restore_last_session) {
|
||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
@@ -615,7 +699,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||||
}, 3000);
|
}, 10000);
|
||||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||||
debouncedRefresh();
|
debouncedRefresh();
|
||||||
});
|
});
|
||||||
@@ -648,12 +732,13 @@
|
|||||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
unlistenSync.push(await listen('sync-done', (event: any) => {
|
||||||
syncState.set({ running: false, error: null });
|
syncState.set({ running: false, error: null });
|
||||||
const cur = get(appConfig);
|
const cur = get(appConfig);
|
||||||
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
|
const ts = event.payload?.last_sync_time;
|
||||||
|
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
|
||||||
}));
|
}));
|
||||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
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)
|
// 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 (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,8 +748,8 @@
|
|||||||
|
|
||||||
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
||||||
unsubDirty = editorDirty.subscribe((d) => {
|
unsubDirty = editorDirty.subscribe((d) => {
|
||||||
const config = get(appConfig);
|
const vc = activeVaultConfig(get(appConfig));
|
||||||
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
|
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
|
||||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||||
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
||||||
}
|
}
|
||||||
@@ -712,7 +797,7 @@
|
|||||||
{#if $mobileView === 'sidebar'}
|
{#if $mobileView === 'sidebar'}
|
||||||
HelixNotes
|
HelixNotes
|
||||||
{:else}
|
{:else}
|
||||||
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
||||||
@@ -780,7 +865,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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')}>
|
<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}>
|
<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"/>
|
<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"/>
|
||||||
@@ -807,6 +892,13 @@
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -823,7 +915,7 @@
|
|||||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
<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()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} />
|
||||||
@@ -882,11 +974,24 @@
|
|||||||
<ResizeHandle onResize={handleSidebarResize} />
|
<ResizeHandle onResize={handleSidebarResize} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
{#if !$notelistCollapsed}
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||||
</div>
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
|
{:else}
|
||||||
|
<div class="notelist-restore-panel">
|
||||||
|
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="20" y2="6" />
|
||||||
|
<line x1="8" y1="12" x2="20" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="20" y2="18" />
|
||||||
|
<polyline points="5 8 3 12 5 16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="editor-panel">
|
<div class="editor-panel">
|
||||||
@@ -905,7 +1010,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SearchPanel />
|
<SearchPanel />
|
||||||
<CommandPalette />
|
<CommandPalette onNavigate={handleViewChanged} />
|
||||||
<SettingsPanel />
|
<SettingsPanel />
|
||||||
<InfoPanel />
|
<InfoPanel />
|
||||||
|
|
||||||
@@ -936,6 +1041,36 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notelist-restore-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notelist-restore-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notelist-restore-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-panel {
|
.editor-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1022,7 +1157,13 @@
|
|||||||
.mobile-shell {
|
.mobile-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
overflow: hidden;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
|
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
|
||||||
import { setTheme, reindex } from '$lib/api';
|
import { setTheme, reindex } from '$lib/api';
|
||||||
|
import { darkThemes } from '$lib/platform';
|
||||||
|
|
||||||
|
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
|
||||||
|
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
|
||||||
|
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
|
||||||
|
|
||||||
|
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
|
||||||
|
$viewMode = mode;
|
||||||
|
$activeNotebook = null;
|
||||||
|
$activeTag = null;
|
||||||
|
$showCommandPalette = false;
|
||||||
|
onNavigate();
|
||||||
|
}
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +38,31 @@
|
|||||||
$showSearch = true;
|
$showSearch = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-all-notes',
|
||||||
|
label: 'Open All Notes',
|
||||||
|
action: () => openView('all')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-quick-access',
|
||||||
|
label: 'Open Quick Access',
|
||||||
|
action: () => openView('quickaccess')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-tasks',
|
||||||
|
label: 'Open Tasks',
|
||||||
|
action: () => openView('tasks')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-daily',
|
||||||
|
label: 'Open Daily Notes',
|
||||||
|
action: () => openView('daily')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-trash',
|
||||||
|
label: 'Open Trash (restore deleted notes)',
|
||||||
|
action: () => openView('trash')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'theme-light',
|
id: 'theme-light',
|
||||||
label: 'Switch to Light Theme',
|
label: 'Switch to Light Theme',
|
||||||
@@ -142,8 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
function applyTheme(t: string) {
|
||||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
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 namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getGraphData } from '$lib/api';
|
import { getGraphData } from '$lib/api';
|
||||||
import { activeNotePath, appConfig } from '$lib/stores/app';
|
import { activeNotePath, appConfig } from '$lib/stores/app';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let { onclose, onnavigate }: {
|
let { onclose, onnavigate }: {
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onnavigate: (path: string, title: string) => void;
|
onnavigate: (path: string, title: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement>(null!);
|
let canvas = $state<HTMLCanvasElement>(null!);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|||||||
@@ -1,22 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showInfo, appConfig } from '$lib/stores/app';
|
import { showInfo, appConfig } from '$lib/stores/app';
|
||||||
import { getVaultStats } from '$lib/api';
|
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
|
import type { OrphanAttachment } from '$lib/api';
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import type { VaultStats } from '$lib/types';
|
import type { VaultStats } from '$lib/types';
|
||||||
|
import {
|
||||||
|
ACTIONS,
|
||||||
|
keybindings,
|
||||||
|
setBinding,
|
||||||
|
resetBinding,
|
||||||
|
bindingFromEvent,
|
||||||
|
bindingToKeys,
|
||||||
|
bindingsEqual,
|
||||||
|
type ActionDef,
|
||||||
|
} from '$lib/keybindings';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
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[] }[] = [
|
||||||
|
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
|
||||||
|
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
|
||||||
|
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// id of the action currently listening for a new key combo, or null.
|
||||||
|
let capturingId = $state<string | null>(null);
|
||||||
|
// id of an action whose row should briefly flash a "already in use" warning.
|
||||||
|
let conflictId = $state<string | null>(null);
|
||||||
|
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function startCapture(id: string) {
|
||||||
|
conflictId = null;
|
||||||
|
capturingId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCapture() {
|
||||||
|
capturingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashConflict(id: string) {
|
||||||
|
conflictId = id;
|
||||||
|
if (conflictTimer) clearTimeout(conflictTimer);
|
||||||
|
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureKeydown(e: KeyboardEvent) {
|
||||||
|
if (!capturingId) return;
|
||||||
|
// Capture phase + stopImmediatePropagation keeps the combo from also
|
||||||
|
// triggering the app-level shortcut handler on window.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
if (e.key === 'Escape') { stopCapture(); return; }
|
||||||
|
|
||||||
|
const binding = bindingFromEvent(e);
|
||||||
|
if (!binding) return; // modifier-only press; keep listening
|
||||||
|
|
||||||
|
// Reject combos already bound to a different action.
|
||||||
|
const map = $keybindings;
|
||||||
|
const target = capturingId;
|
||||||
|
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
|
||||||
|
if (clash) {
|
||||||
|
flashConflict(target);
|
||||||
|
stopCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBinding(target, binding);
|
||||||
|
stopCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!capturingId) return;
|
||||||
|
window.addEventListener('keydown', captureKeydown, true);
|
||||||
|
return () => window.removeEventListener('keydown', captureKeydown, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetKey(e: MouseEvent, id: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
resetBinding(id);
|
||||||
|
if (capturingId === id) stopCapture();
|
||||||
|
}
|
||||||
|
|
||||||
let stats = $state<VaultStats | null>(null);
|
let stats = $state<VaultStats | null>(null);
|
||||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||||
let appVersion = $state('...');
|
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');
|
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
$showInfo = false;
|
$showInfo = false;
|
||||||
activeTab = 'shortcuts';
|
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLink(url: string) {
|
function openLink(url: string) {
|
||||||
@@ -35,8 +115,26 @@
|
|||||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||||
} else {
|
} else {
|
||||||
stats = null;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
@@ -103,6 +201,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="info-credits">
|
||||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||||
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
|
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
|
||||||
@@ -116,27 +233,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="shortcuts-section">
|
<div class="shortcuts-section">
|
||||||
<h4 class="shortcuts-group-title">General</h4>
|
{#if !isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
{/if}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
{#each actionGroups as group}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
|
{#each group.actions as action}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
|
<div class="shortcut-row">
|
||||||
|
<span class="shortcut-desc">{action.label}</span>
|
||||||
<h4 class="shortcuts-group-title">Interface</h4>
|
{#if isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
|
{:else}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
|
<button
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
|
class="shortcut-key-btn"
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
|
class:capturing={capturingId === action.id}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
class:conflict={conflictId === action.id}
|
||||||
|
title="Click to rebind · right-click to reset"
|
||||||
|
onclick={() => startCapture(action.id)}
|
||||||
|
oncontextmenu={(e) => resetKey(e, action.id)}
|
||||||
|
>
|
||||||
|
{#if capturingId === action.id}
|
||||||
|
<span class="capture-prompt">Press keys…</span>
|
||||||
|
{:else if conflictId === action.id}
|
||||||
|
<span class="conflict-msg">Already in use</span>
|
||||||
|
{:else}
|
||||||
|
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Navigation</h4>
|
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>←</kbd></span></div>
|
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>→</kbd></span></div>
|
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Formatting</h4>
|
<h4 class="shortcuts-group-title">Formatting</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||||
@@ -147,6 +276,7 @@
|
|||||||
<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">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">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>
|
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||||
|
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
||||||
@@ -350,6 +480,71 @@
|
|||||||
.info-link:hover {
|
.info-link:hover {
|
||||||
background: var(--accent-light);
|
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 {
|
.shortcuts-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -394,6 +589,48 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shortcuts-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin: -2px -4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn.capturing {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn.conflict {
|
||||||
|
border-color: var(--danger, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-prompt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-msg {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-keys kbd,
|
.shortcut-keys kbd,
|
||||||
.shortcut-desc kbd {
|
.shortcut-desc kbd {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import {
|
import {
|
||||||
notes,
|
notes,
|
||||||
sortedNotes,
|
sortedNotes,
|
||||||
@@ -8,9 +9,12 @@
|
|||||||
activeNotebook,
|
activeNotebook,
|
||||||
activeTag,
|
activeTag,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
appConfig,
|
appConfig,
|
||||||
|
notelistCollapsed,
|
||||||
|
noteOrder,
|
||||||
notebooks,
|
notebooks,
|
||||||
tags,
|
tags,
|
||||||
mobileView
|
mobileView
|
||||||
@@ -34,15 +38,16 @@
|
|||||||
getAllTags,
|
getAllTags,
|
||||||
createDailyNote
|
createDailyNote
|
||||||
} from '$lib/api';
|
} 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 { openNoteWindow } from '$lib/utils/window';
|
||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||||
import TasksView from './TasksView.svelte';
|
import TasksView from './TasksView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.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) => {} }: {
|
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, 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;
|
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||||
onNoteMoved?: () => void;
|
onNoteMoved?: () => void;
|
||||||
onBeforeNoteSwitch?: () => void;
|
onBeforeNoteSwitch?: () => void;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
@@ -52,8 +57,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
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 multiSelectMode = $state(false);
|
||||||
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
||||||
let trashBusy = $state<string | null>(null);
|
let trashBusy = $state<string | null>(null);
|
||||||
@@ -143,6 +146,7 @@
|
|||||||
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let editingNote = $state<string | null>(null);
|
let editingNote = $state<string | null>(null);
|
||||||
let editValue = $state('');
|
let editValue = $state('');
|
||||||
|
let renameInput: HTMLInputElement | null = $state(null);
|
||||||
let movePickerNote = $state<NoteEntry | null>(null);
|
let movePickerNote = $state<NoteEntry | null>(null);
|
||||||
let tagEditNote = $state<NoteEntry | null>(null);
|
let tagEditNote = $state<NoteEntry | null>(null);
|
||||||
let tagEditTags = $state<string[]>([]);
|
let tagEditTags = $state<string[]>([]);
|
||||||
@@ -203,6 +207,29 @@
|
|||||||
let qaDragFrom = $state<number | null>(null);
|
let qaDragFrom = $state<number | null>(null);
|
||||||
let qaDragOver = $state<number | null>(null);
|
let qaDragOver = $state<number | null>(null);
|
||||||
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let noteDragFrom = $state<number | null>(null);
|
||||||
|
let noteDragOver = $state<number | null>(null);
|
||||||
|
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||||
|
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
|
// Virtual scroll
|
||||||
let listContainer = $state<HTMLDivElement>(null!);
|
let listContainer = $state<HTMLDivElement>(null!);
|
||||||
@@ -310,7 +337,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = cacheKey();
|
const key = cacheKey();
|
||||||
const cached = noteCache.get(key);
|
// Never serve the trash view from cache: emptying / restoring / deleting changes it,
|
||||||
|
// and a stale cache left it showing cleared items until an app restart. Always re-read.
|
||||||
|
const cached = $viewMode === 'trash' ? undefined : noteCache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
$notes = cached;
|
$notes = cached;
|
||||||
return;
|
return;
|
||||||
@@ -380,13 +409,14 @@
|
|||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
$activeNotePath = task.note_path;
|
$activeNotePath = task.note_path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
onNoteSelected(task.note_path, content.content);
|
onNoteSelected(task.note_path, content.content, task);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open task note:', e);
|
console.error('Failed to open task note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateNote() {
|
export async function handleCreateNote() {
|
||||||
|
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||||
if ($viewMode === 'daily') {
|
if ($viewMode === 'daily') {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
@@ -410,15 +440,28 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entry = await createNote(nbRelative, 'Untitled');
|
const entry = await createNote(nbRelative, 'Untitled');
|
||||||
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
await refresh();
|
$notes = [entry, ...$notes];
|
||||||
await selectNote(entry);
|
onBeforeNoteSwitch();
|
||||||
|
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||||
|
$activeNotePath = entry.path;
|
||||||
|
$editorDirty = false;
|
||||||
|
onNoteSelected(entry.path, '\n');
|
||||||
onNoteCreated();
|
onNoteCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create note:', e);
|
console.error('Failed to create 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) {
|
async function handleRename(note: NoteEntry) {
|
||||||
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
||||||
editingNote = null;
|
editingNote = null;
|
||||||
@@ -427,6 +470,7 @@
|
|||||||
try {
|
try {
|
||||||
const newTitle = editValue.trim();
|
const newTitle = editValue.trim();
|
||||||
const newPath = await renameNote(note.path, newTitle);
|
const newPath = await renameNote(note.path, newTitle);
|
||||||
|
renameManualNoteOrderPath(note.path, newPath);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
editingNote = null;
|
editingNote = null;
|
||||||
// Update local store immediately (avoid full vault re-scan)
|
// Update local store immediately (avoid full vault re-scan)
|
||||||
@@ -448,6 +492,7 @@
|
|||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
await deleteNote(note.path);
|
await deleteNote(note.path);
|
||||||
|
removeManualNoteOrderPath(note.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
// Remove from local store immediately (avoid full vault re-scan)
|
// Remove from local store immediately (avoid full vault re-scan)
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
@@ -460,6 +505,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||||
|
export async function trashActiveNote() {
|
||||||
|
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||||
|
const note = $notes.find(n => n.path === $activeNotePath);
|
||||||
|
if (note) await handleDelete(note);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRestore(note: NoteEntry) {
|
async function handleRestore(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
@@ -475,6 +527,7 @@
|
|||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
await permanentDelete(note.path);
|
await permanentDelete(note.path);
|
||||||
|
removeManualNoteOrderPath(note.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
if ($activeNotePath === note.path) {
|
if ($activeNotePath === note.path) {
|
||||||
@@ -668,11 +721,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
|
||||||
|
// Merge into (not replace) the order map so reordering notes in one view does
|
||||||
|
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
|
||||||
|
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
|
||||||
|
$noteOrder = { ...$noteOrder, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendManualNoteOrder(path: string) {
|
||||||
|
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
|
||||||
|
$noteOrder = { ...$noteOrder, [path]: nextIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
|
||||||
|
if (!Object.hasOwn($noteOrder, oldPath)) return;
|
||||||
|
const { [oldPath]: index, ...rest } = $noteOrder;
|
||||||
|
$noteOrder = { ...rest, [newPath]: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeManualNoteOrderPath(path: string) {
|
||||||
|
if (!Object.hasOwn($noteOrder, path)) return;
|
||||||
|
const { [path]: _, ...rest } = $noteOrder;
|
||||||
|
$noteOrder = rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNoteDrag() {
|
||||||
|
noteDragFrom = null;
|
||||||
|
noteDragOver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomNoteDrop(targetIndex: number) {
|
||||||
|
if (noteDragFrom === null || !canCustomReorder) {
|
||||||
|
resetNoteDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
|
||||||
|
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
|
||||||
|
resetNoteDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = [...$sortedNotes];
|
||||||
|
const [moved] = arr.splice(noteDragFrom, 1);
|
||||||
|
if (insertAt > noteDragFrom) insertAt--;
|
||||||
|
arr.splice(insertAt, 0, moved);
|
||||||
|
$notes = arr;
|
||||||
|
persistManualNoteOrder(arr);
|
||||||
|
resetNoteDrag();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
movePickerNote = null;
|
movePickerNote = null;
|
||||||
try {
|
try {
|
||||||
const newPath = await moveNote(note.path, destPath);
|
const newPath = await moveNote(note.path, destPath);
|
||||||
|
renameManualNoteOrderPath(note.path, newPath);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
if ($activeNotePath === note.path) {
|
if ($activeNotePath === note.path) {
|
||||||
@@ -685,6 +787,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 } {
|
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 (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||||
@@ -699,10 +814,13 @@
|
|||||||
contextMenu = { x, y, note };
|
contextMenu = { x, y, note };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(note: NoteEntry) {
|
async function startRename(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
editingNote = note.path;
|
editingNote = note.path;
|
||||||
editValue = note.meta.title;
|
editValue = note.meta.title;
|
||||||
|
await tick();
|
||||||
|
renameInput?.focus();
|
||||||
|
renameInput?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNotebookPath(note: NoteEntry): string {
|
function getNotebookPath(note: NoteEntry): string {
|
||||||
@@ -771,6 +889,7 @@
|
|||||||
async function handleBatchDelete() {
|
async function handleBatchDelete() {
|
||||||
const toDelete = new Set(selectedPaths);
|
const toDelete = new Set(selectedPaths);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
|
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||||
$activeNote = null;
|
$activeNote = null;
|
||||||
@@ -806,6 +925,7 @@
|
|||||||
async function handleBatchPermanentDelete() {
|
async function handleBatchPermanentDelete() {
|
||||||
const toDelete = new Set(selectedPaths);
|
const toDelete = new Set(selectedPaths);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
|
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||||
$activeNote = null;
|
$activeNote = null;
|
||||||
@@ -831,6 +951,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSortMode(mode: SortMode) {
|
function setSortMode(mode: SortMode) {
|
||||||
|
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
|
||||||
|
persistManualNoteOrder($sortedNotes);
|
||||||
|
}
|
||||||
$sortMode = mode;
|
$sortMode = mode;
|
||||||
sortMenu = null;
|
sortMenu = null;
|
||||||
}
|
}
|
||||||
@@ -854,6 +977,16 @@
|
|||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span class="list-title">{viewTitle}</span>
|
<span class="list-title">{viewTitle}</span>
|
||||||
<div class="list-actions">
|
<div class="list-actions">
|
||||||
|
{#if !isMobile}
|
||||||
|
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||||
|
<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="4" y1="6" x2="16" y2="6" />
|
||||||
|
<line x1="4" y1="12" x2="16" y2="12" />
|
||||||
|
<line x1="4" y1="18" x2="16" y2="18" />
|
||||||
|
<polyline points="19 8 21 12 19 16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if $viewMode !== 'tasks'}
|
{#if $viewMode !== 'tasks'}
|
||||||
{#if isAndroid}
|
{#if isAndroid}
|
||||||
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
||||||
@@ -867,7 +1000,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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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)`}>
|
<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">
|
<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" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
@@ -883,8 +1023,14 @@
|
|||||||
<span class="selection-count">{selectedPaths.size} selected</span>
|
<span class="selection-count">{selectedPaths.size} selected</span>
|
||||||
<div class="selection-actions">
|
<div class="selection-actions">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
|
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
|
||||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
|
<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}
|
{:else}
|
||||||
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
||||||
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
||||||
@@ -965,7 +1111,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||||
{#if $viewMode === 'tasks'}
|
{#if $viewMode === 'tasks'}
|
||||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1007,6 +1154,8 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<p>Trash is empty</p>
|
<p>Trash is empty</p>
|
||||||
|
{:else if $viewMode === 'quickaccess'}
|
||||||
|
<p>No starred notes</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>No notes yet</p>
|
<p>No notes yet</p>
|
||||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||||
@@ -1045,14 +1194,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="height: {topPad}px"></div>
|
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
|
||||||
{#each visibleNotes as note, i (note.path)}
|
|
||||||
{@const noteIndex = startIndex + i}
|
|
||||||
{#if editingNote === note.path}
|
{#if editingNote === note.path}
|
||||||
<div class="note-item active">
|
<div class="note-item active">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="rename-input"
|
class="rename-input"
|
||||||
|
bind:this={renameInput}
|
||||||
bind:value={editValue}
|
bind:value={editValue}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') handleRename(note);
|
if (e.key === 'Enter') handleRename(note);
|
||||||
@@ -1070,7 +1218,15 @@
|
|||||||
class:compact={compact}
|
class:compact={compact}
|
||||||
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
||||||
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
||||||
|
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
|
||||||
|
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
|
||||||
onclick={(e) => handleNoteClick(e, note)}
|
onclick={(e) => handleNoteClick(e, note)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(note);
|
||||||
|
}
|
||||||
|
}}
|
||||||
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
||||||
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
||||||
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
||||||
@@ -1097,6 +1253,12 @@
|
|||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (canCustomReorder) {
|
||||||
|
noteDragFrom = noteIndex;
|
||||||
|
e.dataTransfer!.setData('text/plain', note.path);
|
||||||
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||||
} else {
|
} else {
|
||||||
@@ -1111,18 +1273,28 @@
|
|||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||||
qaDragOver = noteIndex;
|
qaDragOver = noteIndex;
|
||||||
|
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = 'move';
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||||
|
noteDragOver = noteIndex;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ondragleave={() => {
|
ondragleave={() => {
|
||||||
if (qaDragOver === noteIndex) qaDragOver = null;
|
if (qaDragOver === noteIndex) qaDragOver = null;
|
||||||
|
if (noteDragOver === noteIndex) noteDragOver = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => {
|
ondrop={(e) => {
|
||||||
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleQaDrop(noteIndex);
|
handleQaDrop(noteIndex);
|
||||||
|
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCustomNoteDrop(noteIndex);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
|
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
|
||||||
>
|
>
|
||||||
{#if compact}
|
{#if compact}
|
||||||
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
||||||
@@ -1173,11 +1345,35 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
{#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}
|
{/if}
|
||||||
{/each}
|
{/snippet}
|
||||||
<div style="height: {bottomPad}px"></div>
|
|
||||||
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1352,6 +1548,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Move to...
|
Move to...
|
||||||
</button>
|
</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}
|
{#if !isMobile}
|
||||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
<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">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -1401,6 +1605,18 @@
|
|||||||
Title
|
Title
|
||||||
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
|
||||||
|
<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" />
|
||||||
|
<circle cx="4" cy="6" r="1" />
|
||||||
|
<circle cx="4" cy="12" r="1" />
|
||||||
|
<circle cx="4" cy="18" r="1" />
|
||||||
|
</svg>
|
||||||
|
Custom
|
||||||
|
{#if $sortMode === 'custom'}<span class="sort-check">✓</span>{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1462,17 +1678,19 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar {
|
.note-list-section {
|
||||||
width: 8px;
|
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;
|
||||||
}
|
}
|
||||||
|
.note-list-section:first-child {
|
||||||
.list-content::-webkit-scrollbar-thumb {
|
padding-top: 6px;
|
||||||
background: var(--text-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item {
|
.note-item {
|
||||||
@@ -1486,6 +1704,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item:hover {
|
.note-item:hover {
|
||||||
@@ -1532,6 +1751,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selection-action {
|
.selection-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1546,6 +1768,37 @@
|
|||||||
background: var(--bg-hover);
|
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 {
|
.selection-action.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||||
@@ -1923,11 +2176,13 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item.qa-drag-above {
|
.note-item.qa-drag-above,
|
||||||
|
.note-item.note-drag-above {
|
||||||
border-top: 2px solid var(--accent);
|
border-top: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item.qa-drag-below {
|
.note-item.qa-drag-below,
|
||||||
|
.note-item.note-drag-below {
|
||||||
border-bottom: 2px solid var(--accent);
|
border-bottom: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
theme
|
theme
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { readNote } from '$lib/api';
|
import { readNote } from '$lib/api';
|
||||||
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
import type { FileEvent } from '$lib/types';
|
import type { FileEvent } from '$lib/types';
|
||||||
|
|
||||||
let { notePath }: { notePath: string } = $props();
|
let { notePath }: { notePath: string } = $props();
|
||||||
|
|
||||||
|
import { darkThemes } from '$lib/platform';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
let editor = $state<Editor>(null!);
|
let editor = $state<Editor>(null!);
|
||||||
@@ -35,8 +38,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const t = $theme || 'system';
|
const t = $theme || 'system';
|
||||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
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 namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
@@ -72,13 +74,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const action = matchAction(e, $keybindings);
|
||||||
if (mod && !e.shiftKey && e.code === 'KeyS') {
|
if (action === 'save') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.forceSave();
|
editor?.forceSave();
|
||||||
return;
|
} else if (action === 'toggle-source') {
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && e.code === 'KeyM') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
$sourceMode = !$sourceMode;
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,11 @@
|
|||||||
if (!$editorDirty) {
|
if (!$editorDirty) {
|
||||||
try {
|
try {
|
||||||
const content = await readNote(notePath);
|
const content = await readNote(notePath);
|
||||||
$activeNote = content;
|
// Ignore the file-watcher echo of our own save; only reload genuine external edits.
|
||||||
editor?.loadNote(notePath, content.content);
|
if (content.content.trim() !== (editor?.getCurrentBody() ?? '').trim()) {
|
||||||
|
$activeNote = content;
|
||||||
|
editor?.loadNote(notePath, content.content);
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<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 { searchNotes, readNote } from '$lib/api';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
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 query = $state('');
|
||||||
let results = $state<SearchResult[]>([]);
|
let results = $state<SearchResult[]>([]);
|
||||||
@@ -83,7 +84,16 @@
|
|||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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) {
|
async function openResult(result: SearchResult) {
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +102,19 @@
|
|||||||
$activeNotePath = result.path;
|
$activeNotePath = result.path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
$showSearch = 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';
|
if (isMobile) $mobileView = 'editor';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open search result:', e);
|
console.error('Failed to open search result:', e);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import {
|
import {
|
||||||
notebooks,
|
notebooks,
|
||||||
notes,
|
notes,
|
||||||
@@ -20,26 +21,47 @@
|
|||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder
|
notebookOrder
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
|
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { readFile } from '@tauri-apps/plugin-fs';
|
import { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import type { NotebookEntry } from '$lib/types';
|
import type { NotebookEntry } from '$lib/types';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let { onViewChanged = () => {} }: {
|
let { onViewChanged = () => {} }: {
|
||||||
onViewChanged?: () => void;
|
onViewChanged?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
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.
|
||||||
|
const anyNavItem = $derived(
|
||||||
|
$appConfig?.show_all_notes !== false ||
|
||||||
|
$appConfig?.show_quick_access !== false ||
|
||||||
|
$appConfig?.show_tasks !== false ||
|
||||||
|
$appConfig?.show_daily_notes !== false ||
|
||||||
|
$appConfig?.show_trash !== false
|
||||||
|
);
|
||||||
|
|
||||||
let editingNotebook = $state<string | null>(null);
|
let editingNotebook = $state<string | null>(null);
|
||||||
let editValue = $state('');
|
let editValue = $state('');
|
||||||
|
let renameInput: HTMLInputElement | null = $state(null);
|
||||||
let newNotebookName = $state('');
|
let newNotebookName = $state('');
|
||||||
let showNewNotebook = $state(false);
|
let showNewNotebook = $state(false);
|
||||||
|
let newNotebookInput: HTMLInputElement | null = $state(null);
|
||||||
let dropTargetPath = $state<string | null>(null);
|
let dropTargetPath = $state<string | null>(null);
|
||||||
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
||||||
let draggedNotebookPath = $state<string | 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.
|
// Apply manual ordering recursively when in 'manual' sort mode.
|
||||||
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
||||||
@@ -59,14 +81,30 @@
|
|||||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tagsCollapsed = $state(true);
|
let tagsCollapsed = $state(true);
|
||||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||||
function toggleCollapse(path: string, e: MouseEvent) {
|
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ($collapsedNotebooks.includes(path)) {
|
if ($collapsedNotebooks.includes(nb.path)) {
|
||||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path);
|
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||||
} else {
|
} else {
|
||||||
$collapsedNotebooks = [...$collapsedNotebooks, path];
|
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Collapse-all / expand-all: toggles every notebook that has children.
|
||||||
|
function collectParentPaths(tree: NotebookEntry[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const nb of tree) {
|
||||||
|
if (nb.children.length > 0) {
|
||||||
|
out.push(nb.path);
|
||||||
|
out.push(...collectParentPaths(nb.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const allParentPaths = $derived(collectParentPaths($notebooks));
|
||||||
|
const allCollapsed = $derived(allParentPaths.length > 0 && allParentPaths.every((p) => $collapsedNotebooks.includes(p)));
|
||||||
|
function toggleAllCollapse() {
|
||||||
|
$collapsedNotebooks = allCollapsed ? [] : [...allParentPaths];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
@@ -108,6 +146,10 @@
|
|||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
if (!vault) return;
|
if (!vault) return;
|
||||||
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
||||||
|
if (isMobile) {
|
||||||
|
onViewChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,9 +159,13 @@
|
|||||||
onViewChanged();
|
onViewChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNotebook(nb: NotebookEntry) {
|
async function selectNotebook(nb: NotebookEntry) {
|
||||||
// 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 ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||||
|
if (isMobile) {
|
||||||
|
onViewChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -166,11 +212,52 @@
|
|||||||
|
|
||||||
let newNotebookParent = $state<NotebookEntry | null>(null);
|
let newNotebookParent = $state<NotebookEntry | null>(null);
|
||||||
|
|
||||||
|
function getNotebookNameSegments(name: string): string[] | null {
|
||||||
|
const segments = name.split('/').map((segment) => segment.trim());
|
||||||
|
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusNewNotebookInput() {
|
||||||
|
await tick();
|
||||||
|
newNotebookInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNewNotebookInput() {
|
||||||
|
if (showNewNotebook) {
|
||||||
|
showNewNotebook = false;
|
||||||
|
newNotebookName = '';
|
||||||
|
newNotebookParent = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
|
||||||
|
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||||
|
showNewNotebook = true;
|
||||||
|
await focusNewNotebookInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewNotebookFromSidebar(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
|
||||||
|
|
||||||
|
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||||
|
newNotebookName = '';
|
||||||
|
showNewNotebook = true;
|
||||||
|
await focusNewNotebookInput();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateNotebook() {
|
async function handleCreateNotebook() {
|
||||||
if (!newNotebookName.trim()) return;
|
if (!newNotebookName.trim()) return;
|
||||||
try {
|
try {
|
||||||
|
const trimmedName = newNotebookName.trim();
|
||||||
const parentRel = newNotebookParent?.relative_path ?? null;
|
const parentRel = newNotebookParent?.relative_path ?? null;
|
||||||
await createNotebook(parentRel, newNotebookName.trim());
|
const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await createNotebook(parentRel, name);
|
||||||
newNotebookName = '';
|
newNotebookName = '';
|
||||||
showNewNotebook = false;
|
showNewNotebook = false;
|
||||||
newNotebookParent = null;
|
newNotebookParent = null;
|
||||||
@@ -180,7 +267,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNewSubNotebook(nb: NotebookEntry) {
|
function handleNewNotebookKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateNotebook();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showNewNotebook = false;
|
||||||
|
newNotebookName = '';
|
||||||
|
newNotebookParent = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && newNotebookParent && newNotebookName.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
newNotebookParent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewSubNotebook(nb: NotebookEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
newNotebookParent = nb;
|
newNotebookParent = nb;
|
||||||
newNotebookName = '';
|
newNotebookName = '';
|
||||||
@@ -189,6 +293,7 @@
|
|||||||
if ($collapsedNotebooks.includes(nb.path)) {
|
if ($collapsedNotebooks.includes(nb.path)) {
|
||||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||||
}
|
}
|
||||||
|
await focusNewNotebookInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRename(nb: NotebookEntry) {
|
async function handleRename(nb: NotebookEntry) {
|
||||||
@@ -255,30 +360,34 @@
|
|||||||
|
|
||||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropTargetPath = null;
|
|
||||||
const srcPath = e.dataTransfer?.getData('text/plain');
|
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;
|
draggedNotebookPath = null;
|
||||||
// Don't move onto self or descendant
|
// 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
|
// Don't move if already in that parent
|
||||||
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
const parentDir = parentOf(srcPath);
|
||||||
if (parentDir === destPath) return;
|
if (parentDir === destPath) return;
|
||||||
try {
|
try {
|
||||||
const oldName = srcPath.split('/').pop() || '';
|
const oldName = baseOf(srcPath);
|
||||||
const newBasePath = destPath + '/' + oldName;
|
const newBasePath = joinPath(destPath, oldName);
|
||||||
await moveNotebook(srcPath, destPath);
|
await moveNotebook(srcPath, destPath);
|
||||||
// Update collapsedNotebooks paths
|
// Update collapsedNotebooks paths
|
||||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||||
if (p === srcPath) return newBasePath;
|
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;
|
return p;
|
||||||
});
|
});
|
||||||
// Update active notebook/note if inside the moved notebook
|
// 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();
|
selectAllNotes();
|
||||||
}
|
}
|
||||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||||
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
||||||
$activeNotePath = newNotePath;
|
$activeNotePath = newNotePath;
|
||||||
$activeNote = await readNote(newNotePath);
|
$activeNote = await readNote(newNotePath);
|
||||||
@@ -290,10 +399,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[] {
|
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) {
|
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) {
|
if (nb.children.length > 0) {
|
||||||
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
||||||
if (found.length > 0) return found;
|
if (found.length > 0) return found;
|
||||||
@@ -307,41 +480,39 @@
|
|||||||
dropPosition = null;
|
dropPosition = null;
|
||||||
draggedNotebookPath = null;
|
draggedNotebookPath = null;
|
||||||
if (srcPath === targetPath) return;
|
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;
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
if (!vaultRoot) return;
|
if (!vaultRoot) return;
|
||||||
|
|
||||||
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
const srcParent = parentOf(srcPath);
|
||||||
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
const targetParent = parentOf(targetPath);
|
||||||
let actualSrcPath = srcPath;
|
let actualSrcPath = srcPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: if different parents, move src into target's parent first
|
// Step 1: if different parents, move src into target's parent first
|
||||||
if (srcParent !== targetParent) {
|
if (srcParent !== targetParent) {
|
||||||
const oldName = srcPath.split('/').pop() || '';
|
const oldName = baseOf(srcPath);
|
||||||
const newPath = targetParent === vaultRoot
|
const newPath = joinPath(targetParent, oldName);
|
||||||
? vaultRoot + '/' + oldName
|
|
||||||
: targetParent + '/' + oldName;
|
|
||||||
await moveNotebook(srcPath, targetParent);
|
await moveNotebook(srcPath, targetParent);
|
||||||
// Migrate paths in collapsedNotebooks
|
// Migrate paths in collapsedNotebooks
|
||||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||||
if (p === srcPath) return newPath;
|
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;
|
return p;
|
||||||
});
|
});
|
||||||
// Migrate paths in notebookOrder
|
// Migrate paths in notebookOrder
|
||||||
const migrated: Record<string, number> = {};
|
const migrated: Record<string, number> = {};
|
||||||
for (const [p, v] of Object.entries($notebookOrder)) {
|
for (const [p, v] of Object.entries($notebookOrder)) {
|
||||||
if (p === srcPath) migrated[newPath] = v;
|
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;
|
else migrated[p] = v;
|
||||||
}
|
}
|
||||||
$notebookOrder = migrated;
|
$notebookOrder = migrated;
|
||||||
// Update active notebook/note
|
// Update active notebook/note
|
||||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
}
|
}
|
||||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||||
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
||||||
$activeNotePath = newNotePath;
|
$activeNotePath = newNotePath;
|
||||||
$activeNote = await readNote(newNotePath);
|
$activeNote = await readNote(newNotePath);
|
||||||
@@ -378,10 +549,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(nb: NotebookEntry) {
|
async function startRename(nb: NotebookEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
editingNotebook = nb.path;
|
editingNotebook = nb.path;
|
||||||
editValue = nb.name;
|
editValue = nb.name;
|
||||||
|
await tick();
|
||||||
|
renameInput?.focus();
|
||||||
|
renameInput?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetIcon(nb: NotebookEntry) {
|
async function handleSetIcon(nb: NotebookEntry) {
|
||||||
@@ -470,7 +644,7 @@
|
|||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} />
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
|
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||||
@@ -482,19 +656,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if !$sidebarCollapsed}
|
|
||||||
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
|
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !$sidebarCollapsed}
|
{#if !$sidebarCollapsed}
|
||||||
|
{#if anyNavItem}
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
|
{#if $appConfig?.show_all_notes !== false}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={$viewMode === 'all'}
|
class:active={$viewMode === 'all'}
|
||||||
@@ -506,7 +680,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>All Notes</span>
|
<span>All Notes</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $appConfig?.show_quick_access !== false}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={$viewMode === 'quickaccess'}
|
class:active={$viewMode === 'quickaccess'}
|
||||||
@@ -517,7 +693,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Quick Access</span>
|
<span>Quick Access</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $appConfig?.show_tasks !== false}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={$viewMode === 'tasks'}
|
class:active={$viewMode === 'tasks'}
|
||||||
@@ -529,7 +707,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $appConfig?.show_daily_notes !== false}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={$viewMode === 'daily'}
|
class:active={$viewMode === 'daily'}
|
||||||
@@ -543,7 +723,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Daily Notes</span>
|
<span>Daily Notes</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $appConfig?.show_trash !== false}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={$viewMode === 'trash'}
|
class:active={$viewMode === 'trash'}
|
||||||
@@ -555,13 +737,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Trash</span>
|
<span>Trash</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="section">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="section" ondblclick={startNewNotebookFromSidebar}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="section-header"
|
class="section-header"
|
||||||
class:drop-target={dropTargetPath === '__root__'}
|
class:drop-target={dropTargetPath === '__root__'}
|
||||||
|
data-nb-root
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (draggedNotebookPath) {
|
if (draggedNotebookPath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -578,19 +764,24 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="section-title">Notebooks</span>
|
<span class="section-title">Notebooks</span>
|
||||||
<button class="icon-btn-sm" onclick={() => {
|
<div class="section-actions">
|
||||||
if (showNewNotebook) {
|
{#if allParentPaths.length > 0}
|
||||||
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
|
<button class="icon-btn-sm" onclick={(e) => { e.stopPropagation(); toggleAllCollapse(); }} title={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'} aria-label={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'}>
|
||||||
} else {
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
|
{#if allCollapsed}
|
||||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
|
||||||
showNewNotebook = true;
|
{:else}
|
||||||
}
|
<path d="m7 20 5-5 5 5" /><path d="m7 4 5 5 5-5" />
|
||||||
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
{/if}
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
</button>
|
||||||
</svg>
|
{/if}
|
||||||
</button>
|
<button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
||||||
|
<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="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showNewNotebook}
|
{#if showNewNotebook}
|
||||||
@@ -601,13 +792,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<input
|
<input
|
||||||
|
bind:this={newNotebookInput}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newNotebookName}
|
bind:value={newNotebookName}
|
||||||
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
|
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
|
||||||
onkeydown={(e) => {
|
onkeydown={handleNewNotebookKeydown}
|
||||||
if (e.key === 'Enter') handleCreateNotebook();
|
|
||||||
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -619,12 +808,12 @@
|
|||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||||
onclick={selectUnfiled}
|
onclick={selectUnfiled}
|
||||||
>
|
>
|
||||||
<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">
|
<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" />
|
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<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>
|
</svg>
|
||||||
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each sortedNotebooks as nb (nb.path)}
|
{#each sortedNotebooks as nb (nb.path)}
|
||||||
@@ -679,6 +868,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
|
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||||
@@ -698,7 +888,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
@@ -761,10 +950,11 @@
|
|||||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||||
{#if editingNotebook === nb.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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="rename-input"
|
class="rename-input"
|
||||||
|
bind:this={renameInput}
|
||||||
bind:value={editValue}
|
bind:value={editValue}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') handleRename(nb);
|
if (e.key === 'Enter') handleRename(nb);
|
||||||
@@ -780,12 +970,16 @@
|
|||||||
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
||||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||||
style="padding-left: {8 + depth * 16}px"
|
style="padding-left: {4 + depth * 16}px"
|
||||||
draggable="true"
|
data-nb-path={nb.path}
|
||||||
onclick={() => selectNotebook(nb)}
|
onclick={() => selectNotebook(nb)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(nb);
|
||||||
|
}
|
||||||
|
}}
|
||||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
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) => {
|
ondragover={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedNotebookPath) {
|
if (draggedNotebookPath) {
|
||||||
@@ -814,8 +1008,14 @@
|
|||||||
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
|
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
|
||||||
ondrop={(e) => {
|
ondrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pos = dropPosition;
|
|
||||||
if (draggedNotebookPath) {
|
if (draggedNotebookPath) {
|
||||||
|
// Recompute the drop position from the drop coordinates, not the dragover-set
|
||||||
|
// state: dragleave over the row's child nodes (chevron/icon/label) clears
|
||||||
|
// dropPosition before drop (esp. Windows WebView2), which mis-routed every drop
|
||||||
|
// to "nest" and broke reorder + un-nest. (issue #97)
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const relY = (e.clientY - rect.top) / rect.height;
|
||||||
|
const pos = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
|
||||||
if (pos === 'above' || pos === 'below') {
|
if (pos === 'above' || pos === 'below') {
|
||||||
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
|
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
|
||||||
} else {
|
} else {
|
||||||
@@ -828,7 +1028,7 @@
|
|||||||
>
|
>
|
||||||
{#if hasChildren}
|
{#if hasChildren}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}>
|
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -836,14 +1036,32 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="chevron-spacer"></span>
|
<span class="chevron-spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if iconSrc}
|
{#if iconSrc}
|
||||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||||
{:else}
|
{: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" opacity="0.6">
|
<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="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||||
</svg>
|
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||||
{/if}
|
</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" 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>
|
<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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasChildren && !isCollapsed}
|
{#if hasChildren && !isCollapsed}
|
||||||
@@ -871,6 +1089,29 @@
|
|||||||
max-width: 44px;
|
max-width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
|
||||||
|
the top, info + settings pinned to the bottom. */
|
||||||
|
.sidebar.collapsed .sidebar-header {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-footer-right {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -913,7 +1154,23 @@
|
|||||||
.section {
|
.section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 0 0 4px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* With the top nav hidden, the header's border-bottom is the only divider needed -
|
||||||
|
drop the section's border-top so there's no doubled line under the header. */
|
||||||
|
.sidebar.nav-empty .section {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin the Notebooks header so its collapse/expand-all button stays reachable while the tree
|
||||||
|
scrolls; the section's border-top draws the divider above it, matching the Tags section. (issue #126) */
|
||||||
|
.section > .section-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-section {
|
.tags-section {
|
||||||
@@ -946,6 +1203,11 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.icon-btn, .icon-btn-sm {
|
.icon-btn, .icon-btn-sm {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1048,7 +1310,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px 5px 4px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1129,6 +1391,27 @@
|
|||||||
margin-left: 2px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-list {
|
.tag-list {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
||||||
const suggestions = $derived.by(() => {
|
const suggestions = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return [];
|
|
||||||
const taken = new Set(existing);
|
const taken = new Set(existing);
|
||||||
const names = $tags
|
const names = $tags
|
||||||
.map(([name]) => name)
|
.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) => {
|
names.sort((a, b) => {
|
||||||
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getTasks } from '$lib/api';
|
import { getTasks } from '$lib/api';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { tasksLayout, appConfig } from '$lib/stores/app';
|
import { tasksLayout, tasksHideCompleted, tasksOnlyFlagged, tasksSort, appConfig } from '$lib/stores/app';
|
||||||
import { isAndroid } from '$lib/platform';
|
import { isAndroid } from '$lib/platform';
|
||||||
import type { TaskItem, FileEvent } from '$lib/types';
|
import type { TaskItem, FileEvent } from '$lib/types';
|
||||||
|
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
|
|
||||||
let tasks = $state<TaskItem[]>([]);
|
let tasks = $state<TaskItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let hideCompleted = $state(true);
|
let taskQuery = $state('');
|
||||||
let sortBy = $state<'due' | 'priority' | 'note'>('due');
|
|
||||||
let unlisten: (() => void) | null = null;
|
let unlisten: (() => void) | null = null;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -43,16 +42,24 @@
|
|||||||
|
|
||||||
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
|
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
|
||||||
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
|
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
|
||||||
|
// Free-text task filter: matches the task text or its note title (case-insensitive).
|
||||||
|
function matchesQuery(t: TaskItem): boolean {
|
||||||
|
const q = taskQuery.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return t.text.toLowerCase().includes(q) || t.note_title.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
let list = tasks.slice();
|
let list = tasks.slice();
|
||||||
if (hideCompleted) list = list.filter((t) => !t.completed);
|
if ($tasksHideCompleted) list = list.filter((t) => !t.completed);
|
||||||
|
if ($tasksOnlyFlagged) list = list.filter((t) => !!t.due || !!t.priority);
|
||||||
|
list = list.filter(matchesQuery);
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
|
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
|
||||||
if (sortBy === 'priority') {
|
if ($tasksSort === 'priority') {
|
||||||
const d = prank(a.priority) - prank(b.priority);
|
const d = prank(a.priority) - prank(b.priority);
|
||||||
if (d) return d;
|
if (d) return d;
|
||||||
} else if (sortBy === 'note') {
|
} else if ($tasksSort === 'note') {
|
||||||
const d = a.note_title.localeCompare(b.note_title);
|
const d = a.note_title.localeCompare(b.note_title);
|
||||||
if (d) return d;
|
if (d) return d;
|
||||||
} else {
|
} else {
|
||||||
@@ -110,7 +117,7 @@
|
|||||||
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
|
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
|
||||||
|
|
||||||
// Calendar tasks share the list's hide-completed filter (no sort).
|
// Calendar tasks share the list's hide-completed filter (no sort).
|
||||||
const calBase = $derived(tasks.filter((t) => !hideCompleted || !t.completed));
|
const calBase = $derived(tasks.filter((t) => (!$tasksHideCompleted || !t.completed) && (!$tasksOnlyFlagged || !!t.due || !!t.priority) && matchesQuery(t)));
|
||||||
|
|
||||||
const tasksByDate = $derived.by(() => {
|
const tasksByDate = $derived.by(() => {
|
||||||
const m = new Map<string, TaskItem[]>();
|
const m = new Map<string, TaskItem[]>();
|
||||||
@@ -186,7 +193,9 @@
|
|||||||
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
|
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="tasks-controls">
|
<div class="tasks-controls">
|
||||||
<button class="tasks-ctl" class:active={hideCompleted} onclick={() => (hideCompleted = !hideCompleted)} title="Hide completed tasks">Hide done</button>
|
<input class="tasks-filter" type="text" placeholder="Filter tasks…" bind:value={taskQuery} onkeydown={(e) => { if (e.key === 'Escape') taskQuery = ''; }} aria-label="Filter tasks by text" />
|
||||||
|
<button class="tasks-ctl" class:active={$tasksHideCompleted} onclick={() => ($tasksHideCompleted = !$tasksHideCompleted)} title="Hide completed tasks">Hide done</button>
|
||||||
|
<button class="tasks-ctl" class:active={$tasksOnlyFlagged} onclick={() => ($tasksOnlyFlagged = !$tasksOnlyFlagged)} title="Only show tasks with a due date or priority">Flagged</button>
|
||||||
{#if !isAndroid}
|
{#if !isAndroid}
|
||||||
<div class="tasks-layout">
|
<div class="tasks-layout">
|
||||||
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
|
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
|
||||||
@@ -196,7 +205,7 @@
|
|||||||
{#if viewLayout === 'list'}
|
{#if viewLayout === 'list'}
|
||||||
<div class="tasks-sort">
|
<div class="tasks-sort">
|
||||||
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
|
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
|
||||||
<button class="tasks-ctl" class:active={sortBy === v} onclick={() => (sortBy = v as 'due' | 'priority' | 'note')}>{label}</button>
|
<button class="tasks-ctl" class:active={$tasksSort === v} onclick={() => ($tasksSort = v as 'due' | 'priority' | 'note')}>{label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -366,6 +375,17 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.tasks-filter {
|
||||||
|
width: 150px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tasks-filter::placeholder { color: var(--text-tertiary); }
|
||||||
|
.tasks-filter:focus { outline: none; border-color: var(--accent); }
|
||||||
.tasks-list {
|
.tasks-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -458,7 +478,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--border-color);
|
||||||
opacity: 0;
|
opacity: 0.4;
|
||||||
transition: opacity 0.1s;
|
transition: opacity 0.1s;
|
||||||
}
|
}
|
||||||
.task-row:hover .task-prio.prio-none { opacity: 1; }
|
.task-row:hover .task-prio.prio-none { opacity: 1; }
|
||||||
@@ -529,7 +549,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
opacity: 0;
|
opacity: 0.4;
|
||||||
transition: opacity 0.1s;
|
transition: opacity 0.1s;
|
||||||
}
|
}
|
||||||
.task-row:hover .task-due:not(.set) { opacity: 1; }
|
.task-row:hover .task-due:not(.set) { opacity: 1; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, syncState } from '$lib/stores/app';
|
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||||
import { syncNow } from '$lib/api';
|
import { syncNow } from '$lib/api';
|
||||||
|
|
||||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if $appConfig?.sync_provider === 'webdav'}
|
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||||
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
|
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
<svg width="14" height="14" 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"/>
|
<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"/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { documentDir } from '@tauri-apps/api/path';
|
import { documentDir } from '@tauri-apps/api/path';
|
||||||
import { openVault, getAppConfig } from '$lib/api';
|
import { openVault, removeVault, getAppConfig } from '$lib/api';
|
||||||
import { appConfig, vaultReady } from '$lib/stores/app';
|
import { appConfig, vaultReady } from '$lib/stores/app';
|
||||||
import { isAndroid, isIOS, isMobile } from '$lib/platform';
|
import { isAndroid, isIOS, isMobile } from '$lib/platform';
|
||||||
import type { VaultConfig } from '$lib/types';
|
import type { VaultConfig } from '$lib/types';
|
||||||
@@ -107,6 +107,15 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forgetVault(path: string) {
|
||||||
|
try {
|
||||||
|
await removeVault(path);
|
||||||
|
$appConfig = await getAppConfig();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -205,10 +214,18 @@
|
|||||||
<div class="recent">
|
<div class="recent">
|
||||||
<span class="recent-label">Recent</span>
|
<span class="recent-label">Recent</span>
|
||||||
{#each recentVaults as vault}
|
{#each recentVaults as vault}
|
||||||
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
|
<div class="vault-row">
|
||||||
<span class="vault-name">{vault.name}</span>
|
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
|
||||||
<span class="vault-path">{vault.path}</span>
|
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||||
</button>
|
<span class="vault-path">{vault.path}</span>
|
||||||
|
</button>
|
||||||
|
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 10 10">
|
||||||
|
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -372,6 +389,24 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vault-item.current {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-current-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.vault-name {
|
.vault-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -389,6 +424,48 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vault-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-row .vault-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-row:hover .vault-remove,
|
||||||
|
.vault-remove:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-remove:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-picker.mobile .vault-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.vault-name-input {
|
.vault-name-input {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Obsidian-compatible callouts.
|
||||||
|
//
|
||||||
|
// Callouts are written as blockquotes whose first line is `[!type]`:
|
||||||
|
//
|
||||||
|
// > [!warning] Optional title
|
||||||
|
// > Body markdown...
|
||||||
|
//
|
||||||
|
// A trailing `+` / `-` on the type makes the callout foldable
|
||||||
|
// (`+` = expanded, `-` = collapsed). Types are case-insensitive and have
|
||||||
|
// aliases. Callouts round-trip to this exact syntax so notes stay portable
|
||||||
|
// (no lock-in) and stay compatible with Obsidian.
|
||||||
|
|
||||||
|
// alias (lowercase) -> canonical group used for icon + colour
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
note: 'note',
|
||||||
|
abstract: 'abstract', summary: 'abstract', tldr: 'abstract',
|
||||||
|
info: 'info',
|
||||||
|
todo: 'todo',
|
||||||
|
tip: 'tip', hint: 'tip', important: 'tip',
|
||||||
|
success: 'success', check: 'success', done: 'success',
|
||||||
|
question: 'question', help: 'question', faq: 'question',
|
||||||
|
warning: 'warning', caution: 'warning', attention: 'warning',
|
||||||
|
failure: 'failure', fail: 'failure', missing: 'failure',
|
||||||
|
danger: 'danger', error: 'danger',
|
||||||
|
bug: 'bug',
|
||||||
|
example: 'example',
|
||||||
|
quote: 'quote', cite: 'quote',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a (possibly aliased / cased) type to its canonical styling group.
|
||||||
|
* Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon)
|
||||||
|
* so a `[!decision]` reads as intentional rather than masquerading as a note.
|
||||||
|
*/
|
||||||
|
export function calloutGroup(type: string): string {
|
||||||
|
return ALIASES[(type || '').trim().toLowerCase()] || 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default header label shown when no custom title is set (matches Obsidian). */
|
||||||
|
export function calloutLabel(type: string): string {
|
||||||
|
const t = (type || 'note').trim();
|
||||||
|
return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner SVG markup per canonical group (24x24, stroke = currentColor).
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
note: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||||
|
abstract: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6"/><path d="M9 16h6"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||||||
|
todo: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||||
|
tip: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>',
|
||||||
|
success: '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
question: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
failure: '<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"/>',
|
||||||
|
danger: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||||
|
bug: '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 8-3 2"/><path d="m5 8 3 2"/><path d="m19 16-3-2"/><path d="m5 16 3-2"/><path d="M21 13h-5"/><path d="M3 13h5"/><path d="m15 4-1.5 2.5"/><path d="m9 4 1.5 2.5"/>',
|
||||||
|
example: '<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"/>',
|
||||||
|
quote: '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 0-1 1.03V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
|
||||||
|
custom: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full SVG icon string for a callout type, at the given pixel size. */
|
||||||
|
export function calloutIcon(type: string, size = 18): string {
|
||||||
|
const inner = ICONS[calloutGroup(type)] || ICONS.note;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered list of types offered in the type picker. */
|
||||||
|
export const CALLOUT_MENU: { type: string; label: string }[] = [
|
||||||
|
{ type: 'note', label: 'Note' },
|
||||||
|
{ type: 'abstract', label: 'Abstract' },
|
||||||
|
{ type: 'info', label: 'Info' },
|
||||||
|
{ type: 'todo', label: 'Todo' },
|
||||||
|
{ type: 'tip', label: 'Tip' },
|
||||||
|
{ type: 'success', label: 'Success' },
|
||||||
|
{ type: 'question', label: 'Question' },
|
||||||
|
{ type: 'warning', label: 'Warning' },
|
||||||
|
{ type: 'failure', label: 'Failure' },
|
||||||
|
{ type: 'danger', label: 'Danger' },
|
||||||
|
{ type: 'bug', label: 'Bug' },
|
||||||
|
{ type: 'example', label: 'Example' },
|
||||||
|
{ type: 'quote', label: 'Quote' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// First line of a callout: [!type], optional +/- fold marker, optional title.
|
||||||
|
const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
|
||||||
|
|
||||||
|
const BLOCK_SELECTOR =
|
||||||
|
'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert callout blockquotes produced by markdown-it into
|
||||||
|
* `<div data-callout=...>` blocks that the Callout node parses. Operates in
|
||||||
|
* place on a detached container element. Processes depth-first so nested
|
||||||
|
* callouts (`> > [!todo]`) are converted before their parents.
|
||||||
|
*/
|
||||||
|
export function transformCalloutBlockquotes(root: Element): void {
|
||||||
|
const walk = (el: Element) => {
|
||||||
|
for (const child of Array.from(el.children)) walk(child);
|
||||||
|
if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBlockquote(bq: Element): void {
|
||||||
|
const first = bq.firstElementChild;
|
||||||
|
if (!first || first.tagName !== 'P') return;
|
||||||
|
|
||||||
|
// markdown-it merges the title line and any immediately-following body lines
|
||||||
|
// into one paragraph (separated by "\n"). Split off the first line.
|
||||||
|
const innerHtml = (first as HTMLElement).innerHTML;
|
||||||
|
const nlIdx = innerHtml.indexOf('\n');
|
||||||
|
const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
|
||||||
|
const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
|
||||||
|
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.innerHTML = firstLineHtml;
|
||||||
|
const m = (probe.textContent || '').match(CALLOUT_RE);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const type = m[1].toLowerCase();
|
||||||
|
const foldChar = m[2];
|
||||||
|
const title = (m[3] || '').trim();
|
||||||
|
const foldable = foldChar === '+' || foldChar === '-';
|
||||||
|
const folded = foldChar === '-';
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-callout', type);
|
||||||
|
div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
|
||||||
|
div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
|
||||||
|
if (title) div.setAttribute('data-callout-title', title);
|
||||||
|
|
||||||
|
if (restHtml.trim()) {
|
||||||
|
(first as HTMLElement).innerHTML = restHtml;
|
||||||
|
div.appendChild(first);
|
||||||
|
} else {
|
||||||
|
first.remove();
|
||||||
|
}
|
||||||
|
while (bq.firstChild) div.appendChild(bq.firstChild);
|
||||||
|
|
||||||
|
// content is `block+`; guarantee at least one block.
|
||||||
|
if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
|
||||||
|
|
||||||
|
bq.replaceWith(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
|
||||||
|
* `serializeChild` serializes a child block (the editor's own serializer),
|
||||||
|
* which makes nesting fall out automatically (each `>` line gains another `>`).
|
||||||
|
*/
|
||||||
|
export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
|
||||||
|
const type = node.attrs.type || 'note';
|
||||||
|
const foldable = !!node.attrs.foldable;
|
||||||
|
const folded = !!node.attrs.folded;
|
||||||
|
const title = (node.attrs.title || '').trim();
|
||||||
|
const suffix = foldable ? (folded ? '-' : '+') : '';
|
||||||
|
const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
|
||||||
|
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||||
|
|
||||||
|
const out = [`> ${header}`];
|
||||||
|
const inner = parts.join('\n\n');
|
||||||
|
if (inner.length) {
|
||||||
|
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||||
|
}
|
||||||
|
return out.join('\n') + '\n';
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { getSelectionPair } from '../selectionPairs';
|
||||||
|
|
||||||
|
// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection
|
||||||
|
// without flattening selected inline marks or content to plain text.
|
||||||
|
export const WrapSelectedText = Extension.create({
|
||||||
|
name: 'wrapSelectedText',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('wrapSelectedText'),
|
||||||
|
props: {
|
||||||
|
// Intercept only supported single-character text input over a range.
|
||||||
|
// Everything else falls back to ProseMirror's normal input handling.
|
||||||
|
handleTextInput(view, from, to, text) {
|
||||||
|
const close = getSelectionPair(text);
|
||||||
|
if (!close || from === to) return false;
|
||||||
|
|
||||||
|
const tr = view.state.tr.insertText(close, to).insertText(text, from);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
|
||||||
|
view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Shared source of truth for editor behaviors that wrap an active selection.
|
||||||
|
// Keep this small: only characters users reasonably expect to form pairs.
|
||||||
|
const selectionPairs: Record<string, string> = {
|
||||||
|
'(': ')',
|
||||||
|
'{': '}',
|
||||||
|
'[': ']',
|
||||||
|
"'": "'",
|
||||||
|
'"': '"',
|
||||||
|
'`': '`',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the closing character for a typed wrapper key, or null to pass through.
|
||||||
|
export function getSelectionPair(key: string) {
|
||||||
|
return selectionPairs[key] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getSelectionPair } from '../selectionPairs';
|
||||||
|
|
||||||
|
// Pure source-mode helper: computes the wrapped textarea value and preserved
|
||||||
|
// inner selection so Svelte event handlers stay as glue code.
|
||||||
|
export function wrapTextareaSelection(value: string, start: number, end: number, key: string) {
|
||||||
|
const close = getSelectionPair(key);
|
||||||
|
if (!close || start === end) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end),
|
||||||
|
selectionStart: start + key.length,
|
||||||
|
selectionEnd: end + key.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
|
||||||
|
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
|
||||||
|
// "Backslash", "ArrowLeft", "F11".
|
||||||
|
export interface Binding {
|
||||||
|
mod?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
group: "General" | "Interface" | "Navigation";
|
||||||
|
default: Binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every customizable action and its factory-default binding. These mirror the
|
||||||
|
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
|
||||||
|
export const ACTIONS: ActionDef[] = [
|
||||||
|
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
|
||||||
|
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
|
||||||
|
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
|
||||||
|
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
|
||||||
|
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
|
||||||
|
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
|
||||||
|
|
||||||
|
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
|
||||||
|
{ id: "toggle-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: true, code: "Backslash" } },
|
||||||
|
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
|
||||||
|
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
|
||||||
|
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
|
||||||
|
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
|
||||||
|
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
|
||||||
|
|
||||||
|
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
|
||||||
|
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
|
||||||
|
ACTIONS.map((a) => [a.id, a.default]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "helix-keybindings";
|
||||||
|
|
||||||
|
function loadBindings(): Record<string, Binding> {
|
||||||
|
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
|
||||||
|
try {
|
||||||
|
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const stored = JSON.parse(raw) as Record<string, Binding>;
|
||||||
|
for (const id of Object.keys(merged)) {
|
||||||
|
if (stored[id]) merged[id] = stored[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt storage - fall back to defaults.
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keybindings = writable<Record<string, Binding>>(loadBindings());
|
||||||
|
|
||||||
|
keybindings.subscribe((map) => {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore quota / unavailable storage.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setBinding(id: string, binding: Binding) {
|
||||||
|
keybindings.update((m) => ({ ...m, [id]: binding }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBinding(id: string) {
|
||||||
|
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the keyboard event matches the given binding exactly. Modifier
|
||||||
|
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
|
||||||
|
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
|
||||||
|
if (!b) return false;
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
return (
|
||||||
|
!!b.mod === mod &&
|
||||||
|
!!b.shift === e.shiftKey &&
|
||||||
|
!!b.alt === e.altKey &&
|
||||||
|
e.code === b.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the id of the first action whose binding matches the event, or null.
|
||||||
|
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
|
||||||
|
for (const id of Object.keys(map)) {
|
||||||
|
if (matchBinding(e, map[id])) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a Binding from a captured keydown event. Returns null for events that
|
||||||
|
// are only modifier keys (no real key pressed yet).
|
||||||
|
const MODIFIER_CODES = new Set([
|
||||||
|
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
|
||||||
|
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
|
||||||
|
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
|
||||||
|
return {
|
||||||
|
mod: e.ctrlKey || e.metaKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
alt: e.altKey,
|
||||||
|
code: e.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingsEqual(a: Binding, b: Binding): boolean {
|
||||||
|
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
|
||||||
|
|
||||||
|
const CODE_LABELS: Record<string, string> = {
|
||||||
|
Backslash: "\\",
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Comma: ",",
|
||||||
|
Period: ".",
|
||||||
|
Slash: "/",
|
||||||
|
Semicolon: ";",
|
||||||
|
Quote: "'",
|
||||||
|
Backquote: "`",
|
||||||
|
Minus: "-",
|
||||||
|
Equal: "=",
|
||||||
|
Space: "Space",
|
||||||
|
Enter: "Enter",
|
||||||
|
Tab: "Tab",
|
||||||
|
ArrowLeft: "←",
|
||||||
|
ArrowRight: "→",
|
||||||
|
ArrowUp: "↑",
|
||||||
|
ArrowDown: "↓",
|
||||||
|
};
|
||||||
|
|
||||||
|
function codeToLabel(code: string): string {
|
||||||
|
if (code.startsWith("Key")) return code.slice(3);
|
||||||
|
if (code.startsWith("Digit")) return code.slice(5);
|
||||||
|
if (code.startsWith("Numpad")) return code.slice(6);
|
||||||
|
return CODE_LABELS[code] ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
|
||||||
|
export function bindingToKeys(b: Binding): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
|
||||||
|
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
|
||||||
|
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
|
||||||
|
keys.push(codeToLabel(b.code));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
@@ -1,7 +1,50 @@
|
|||||||
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user
|
// Platform is the compile-time build target, injected by the backend into
|
||||||
// agent (not the literal "ios"), so they must be matched explicitly.
|
// window.__HELIX_PLATFORM__ before app scripts run (see src-tauri/src/lib.rs). The UA is only a
|
||||||
|
// fallback when that global is absent (SSR/prerender, plain-browser dev): some desktop WebKitGTK
|
||||||
|
// builds report a mobile-looking user-agent. (#63)
|
||||||
|
type HelixPlatform = { mobile: boolean; android: boolean; ios: boolean };
|
||||||
|
|
||||||
|
const injected: HelixPlatform | undefined =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __HELIX_PLATFORM__?: HelixPlatform }).__HELIX_PLATFORM__
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||||
|
|
||||||
export const isAndroid = /android/i.test(ua);
|
export const isAndroid = injected ? injected.android : /android/i.test(ua);
|
||||||
export const isIOS = /iphone|ipad|ipod/i.test(ua);
|
export const isIOS = injected ? injected.ios : /iphone|ipad|ipod/i.test(ua);
|
||||||
export const isMobile = isAndroid || isIOS;
|
export const isMobile = injected ? injected.mobile : isAndroid || isIOS;
|
||||||
|
|
||||||
|
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
|
||||||
|
// `dark` class on the root element. Keep this in sync when adding new themes.
|
||||||
|
export const darkThemes = [
|
||||||
|
'dark',
|
||||||
|
'solarized-dark',
|
||||||
|
'catppuccin',
|
||||||
|
'nord',
|
||||||
|
'tokyo-night',
|
||||||
|
'github-dark',
|
||||||
|
'dracula',
|
||||||
|
'blueberry',
|
||||||
|
'forest-green',
|
||||||
|
'gruvbox',
|
||||||
|
'midnight-tide',
|
||||||
|
'cherry-blossom',
|
||||||
|
'synthwave',
|
||||||
|
'ember',
|
||||||
|
'moonlit',
|
||||||
|
'dark-coffee',
|
||||||
|
'crimson',
|
||||||
|
'material-dark',
|
||||||
|
'monokai',
|
||||||
|
'rose-pine',
|
||||||
|
'everforest',
|
||||||
|
'horizon',
|
||||||
|
'cyberpunk',
|
||||||
|
'black',
|
||||||
|
'one-dark',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isDarkTheme(theme: string): boolean {
|
||||||
|
return darkThemes.includes(theme) || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
|
import { isMobile } from "$lib/platform";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
CustomTheme,
|
||||||
NoteEntry,
|
NoteEntry,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NotebookEntry,
|
NotebookEntry,
|
||||||
|
VaultConfig,
|
||||||
VaultState,
|
VaultState,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
SortMode,
|
SortMode,
|
||||||
@@ -11,15 +14,28 @@ import type {
|
|||||||
|
|
||||||
// App state
|
// App state
|
||||||
export const appConfig = writable<AppConfig | null>(null);
|
export const appConfig = writable<AppConfig | null>(null);
|
||||||
|
|
||||||
|
// The active vault's config entry (where per-vault WebDAV sync settings live).
|
||||||
|
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
|
||||||
|
if (!c?.active_vault) return null;
|
||||||
|
return c.vaults.find((v) => v.path === c.active_vault) ?? null;
|
||||||
|
}
|
||||||
export const vaultReady = writable(false);
|
export const vaultReady = writable(false);
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
export const viewMode = writable<ViewMode>("all");
|
export const viewMode = writable<ViewMode>("all");
|
||||||
export const sortMode = writable<SortMode>("modified");
|
export const sortMode = writable<SortMode>("modified");
|
||||||
|
// Group the notes list under relative-date headers (All Notes, date sorts only). Persisted per vault.
|
||||||
|
export const groupNotesByDate = writable<boolean>(false);
|
||||||
export const tasksLayout = writable<"list" | "calendar">("list");
|
export const tasksLayout = writable<"list" | "calendar">("list");
|
||||||
|
export const tasksHideCompleted = writable<boolean>(true);
|
||||||
|
export const tasksOnlyFlagged = writable<boolean>(false);
|
||||||
|
export const tasksSort = writable<"due" | "priority" | "note">("due");
|
||||||
export const sidebarCollapsed = writable(false);
|
export const sidebarCollapsed = writable(false);
|
||||||
|
export const notelistCollapsed = writable(false);
|
||||||
export const sidebarWidth = writable(220);
|
export const sidebarWidth = writable(220);
|
||||||
export const notelistWidth = writable(280);
|
export const notelistWidth = writable(280);
|
||||||
|
export const outlineWidth = writable(220);
|
||||||
export const searchQuery = writable("");
|
export const searchQuery = writable("");
|
||||||
export const showCommandPalette = writable(false);
|
export const showCommandPalette = writable(false);
|
||||||
export const showSearch = writable(false);
|
export const showSearch = writable(false);
|
||||||
@@ -36,6 +52,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
|
|||||||
);
|
);
|
||||||
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
|
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
|
||||||
export const notebookOrder = writable<Record<string, number>>({});
|
export const notebookOrder = writable<Record<string, number>>({});
|
||||||
|
export const noteOrder = writable<Record<string, number>>({});
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
export const notebooks = writable<NotebookEntry[]>([]);
|
export const notebooks = writable<NotebookEntry[]>([]);
|
||||||
@@ -67,6 +84,7 @@ export const readOnly = writable(false);
|
|||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
export const theme = writable<string>("system");
|
export const theme = writable<string>("system");
|
||||||
|
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||||
|
|
||||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||||
// whoever triggered it (manual button, settings, interval, on-change).
|
// whoever triggered it (manual button, settings, interval, on-change).
|
||||||
@@ -82,8 +100,19 @@ export const updateAvailable = writable<{
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
export const updateObj = writable<any>(null);
|
export const updateObj = writable<any>(null);
|
||||||
export const installType = writable<string>("native");
|
export const installType = writable<string>("native");
|
||||||
|
// True only on the Android/iOS build. Reconfirmed from the backend at startup (see +layout). (#63)
|
||||||
|
export const platformIsMobile = writable<boolean>(isMobile);
|
||||||
export const androidApkUrl = writable<string | null>(null);
|
export const androidApkUrl = writable<string | null>(null);
|
||||||
|
|
||||||
|
// Install types that handle their own updates (in-app auto-updater, or a
|
||||||
|
// package-manager notice for deb/aur). Anything else - e.g. a distro repo build
|
||||||
|
// that sets the HELIXNOTES_INSTALL_TYPE build flag (Solus, etc.) - is "managed":
|
||||||
|
// the app does no update check and shows no update UI at all.
|
||||||
|
const SELF_UPDATING_INSTALL_TYPES = ["macos", "windows", "deb", "aur", "appimage", "native", "android"];
|
||||||
|
export function isManagedInstall(type: string): boolean {
|
||||||
|
return !SELF_UPDATING_INSTALL_TYPES.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkForUpdate() {
|
export async function checkForUpdate() {
|
||||||
try {
|
try {
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
@@ -159,16 +188,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
|
|||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
export const sortedNotes = derived(
|
export const sortedNotes = derived(
|
||||||
[notes, sortMode, viewMode],
|
[notes, sortMode, viewMode, noteOrder],
|
||||||
([$notes, $sortMode, $viewMode]) => {
|
([$notes, $sortMode, $viewMode, $noteOrder]) => {
|
||||||
// Quick Access preserves stored order
|
// Quick Access preserves stored order
|
||||||
if ($viewMode === "quickaccess") return $notes;
|
if ($viewMode === "quickaccess") return $notes;
|
||||||
|
const customSortable =
|
||||||
|
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
|
||||||
|
|
||||||
const pinned = $notes.filter((n) => n.meta.pinned);
|
const pinned = $notes.filter((n) => n.meta.pinned);
|
||||||
const unpinned = $notes.filter((n) => !n.meta.pinned);
|
const unpinned = $notes.filter((n) => !n.meta.pinned);
|
||||||
|
|
||||||
const sortFn = (a: NoteEntry, b: NoteEntry) => {
|
const sortFn = (a: NoteEntry, b: NoteEntry) => {
|
||||||
switch ($sortMode) {
|
switch ($sortMode) {
|
||||||
|
case "custom": {
|
||||||
|
if (!customSortable) {
|
||||||
|
return (
|
||||||
|
new Date(b.meta.modified).getTime() -
|
||||||
|
new Date(a.meta.modified).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return a.meta.title.localeCompare(b.meta.title);
|
||||||
|
}
|
||||||
case "title":
|
case "title":
|
||||||
return a.meta.title.localeCompare(b.meta.title);
|
return a.meta.title.localeCompare(b.meta.title);
|
||||||
case "created":
|
case "created":
|
||||||
@@ -194,28 +237,37 @@ export const vaultState = derived(
|
|||||||
activeNotePath,
|
activeNotePath,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder,
|
notebookOrder,
|
||||||
|
noteOrder,
|
||||||
],
|
],
|
||||||
([
|
([
|
||||||
$activeNotePath,
|
$activeNotePath,
|
||||||
$sidebarWidth,
|
$sidebarWidth,
|
||||||
$notelistWidth,
|
$notelistWidth,
|
||||||
|
$outlineWidth,
|
||||||
$sidebarCollapsed,
|
$sidebarCollapsed,
|
||||||
|
$notelistCollapsed,
|
||||||
$collapsedNotebooks,
|
$collapsedNotebooks,
|
||||||
$notebookSortMode,
|
$notebookSortMode,
|
||||||
$notebookOrder,
|
$notebookOrder,
|
||||||
|
$noteOrder,
|
||||||
]) => {
|
]) => {
|
||||||
return {
|
return {
|
||||||
last_open_note: $activeNotePath,
|
last_open_note: $activeNotePath,
|
||||||
sidebar_width: $sidebarWidth,
|
sidebar_width: $sidebarWidth,
|
||||||
notelist_width: $notelistWidth,
|
notelist_width: $notelistWidth,
|
||||||
|
outline_width: $outlineWidth,
|
||||||
sidebar_collapsed: $sidebarCollapsed,
|
sidebar_collapsed: $sidebarCollapsed,
|
||||||
|
notelist_collapsed: $notelistCollapsed,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
notebook_sort_mode: $notebookSortMode,
|
notebook_sort_mode: $notebookSortMode,
|
||||||
notebook_order: $notebookOrder,
|
notebook_order: $notebookOrder,
|
||||||
|
note_order: $noteOrder,
|
||||||
} satisfies VaultState;
|
} satisfies VaultState;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ export interface NoteContent {
|
|||||||
export interface VaultConfig {
|
export interface VaultConfig {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
// Per-vault WebDAV sync (moved off the global AppConfig).
|
||||||
|
sync_provider?: string | null;
|
||||||
|
webdav_url?: string | null;
|
||||||
|
webdav_username?: string | null;
|
||||||
|
webdav_password?: string | null;
|
||||||
|
sync_on_open?: boolean;
|
||||||
|
sync_on_change?: boolean;
|
||||||
|
sync_interval_minutes?: number;
|
||||||
|
last_sync_time?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomThemeColors {
|
||||||
|
bg_primary: string;
|
||||||
|
bg_secondary: string;
|
||||||
|
bg_tertiary: string;
|
||||||
|
bg_hover: string;
|
||||||
|
bg_active: string;
|
||||||
|
bg_editor: string;
|
||||||
|
text_primary: string;
|
||||||
|
text_secondary: string;
|
||||||
|
border_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomTheme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_dark: boolean;
|
||||||
|
colors: CustomThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -55,10 +83,12 @@ export interface AppConfig {
|
|||||||
font_family: string | null;
|
font_family: string | null;
|
||||||
line_height: number | null;
|
line_height: number | null;
|
||||||
ui_scale: number | null;
|
ui_scale: number | null;
|
||||||
|
content_width: number | null;
|
||||||
compact_notes: boolean;
|
compact_notes: boolean;
|
||||||
show_note_dates: boolean;
|
show_note_dates: boolean;
|
||||||
time_format: string;
|
time_format: string;
|
||||||
week_start: string;
|
week_start: string;
|
||||||
|
daily_title_format: string;
|
||||||
gpu_acceleration: boolean;
|
gpu_acceleration: boolean;
|
||||||
autostart: boolean;
|
autostart: boolean;
|
||||||
pdf_preview: boolean;
|
pdf_preview: boolean;
|
||||||
@@ -66,6 +96,12 @@ export interface AppConfig {
|
|||||||
title_mode: string;
|
title_mode: string;
|
||||||
hide_title_in_body: boolean;
|
hide_title_in_body: boolean;
|
||||||
show_line_numbers: boolean;
|
show_line_numbers: boolean;
|
||||||
|
show_link_arrows: boolean;
|
||||||
|
show_all_notes: boolean;
|
||||||
|
show_quick_access: boolean;
|
||||||
|
show_tasks: boolean;
|
||||||
|
show_daily_notes: boolean;
|
||||||
|
show_trash: boolean;
|
||||||
backup_enabled: boolean;
|
backup_enabled: boolean;
|
||||||
backup_frequency: string;
|
backup_frequency: string;
|
||||||
backup_max_count: number;
|
backup_max_count: number;
|
||||||
@@ -95,21 +131,29 @@ export interface AppConfig {
|
|||||||
sync_on_change: boolean;
|
sync_on_change: boolean;
|
||||||
sync_interval_minutes: number;
|
sync_interval_minutes: number;
|
||||||
last_sync_time: string | null;
|
last_sync_time: string | null;
|
||||||
|
custom_themes: CustomTheme[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultState {
|
export interface VaultState {
|
||||||
last_open_note: string | null;
|
last_open_note: string | null;
|
||||||
sidebar_width: number;
|
sidebar_width: number;
|
||||||
notelist_width: number;
|
notelist_width: number;
|
||||||
|
outline_width?: number;
|
||||||
sidebar_collapsed: boolean;
|
sidebar_collapsed: boolean;
|
||||||
|
notelist_collapsed: boolean;
|
||||||
collapsed_notebooks: string[];
|
collapsed_notebooks: string[];
|
||||||
notebook_sort_mode?: string;
|
notebook_sort_mode?: string;
|
||||||
notebook_order?: Record<string, number>;
|
notebook_order?: Record<string, number>;
|
||||||
|
note_order?: Record<string, number>;
|
||||||
sort_mode?: string;
|
sort_mode?: string;
|
||||||
|
group_notes_by_date?: boolean;
|
||||||
last_view_mode?: string;
|
last_view_mode?: string;
|
||||||
last_notebook?: string | null;
|
last_notebook?: string | null;
|
||||||
last_tag?: string | null;
|
last_tag?: string | null;
|
||||||
tasks_layout?: string;
|
tasks_layout?: string;
|
||||||
|
tasks_hide_completed?: boolean;
|
||||||
|
tasks_only_flagged?: boolean;
|
||||||
|
tasks_sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
@@ -127,6 +171,9 @@ export interface FileEvent {
|
|||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
files_converted: number;
|
files_converted: number;
|
||||||
links_converted: number;
|
links_converted: number;
|
||||||
|
frontmatter_normalized: number;
|
||||||
|
syntax_converted: number;
|
||||||
|
attachments_moved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultStats {
|
export interface VaultStats {
|
||||||
@@ -160,7 +207,7 @@ export interface NoteTitleEntry {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortMode = "modified" | "title" | "created";
|
export type SortMode = "modified" | "title" | "created" | "custom";
|
||||||
export type ViewMode =
|
export type ViewMode =
|
||||||
| "all"
|
| "all"
|
||||||
| "notebook"
|
| "notebook"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function pathParts(path: string): string[] {
|
||||||
|
return path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathRoot(path: string): string {
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
const drive = normalized.match(/^([A-Za-z]:)\//);
|
||||||
|
if (drive) return drive[1].toLowerCase();
|
||||||
|
return normalized.startsWith('/') ? '/' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativePath(fromDirectory: string, targetPath: string): string {
|
||||||
|
const from = pathParts(fromDirectory);
|
||||||
|
const target = pathParts(targetPath);
|
||||||
|
if (pathRoot(fromDirectory) !== pathRoot(targetPath)) return targetPath.replace(/\\/g, '/');
|
||||||
|
const windowsPath = /^[A-Za-z]:[\\/]/.test(fromDirectory) || /^[A-Za-z]:[\\/]/.test(targetPath);
|
||||||
|
const equal = windowsPath
|
||||||
|
? (left: string, right: string) => left.toLowerCase() === right.toLowerCase()
|
||||||
|
: (left: string, right: string) => left === right;
|
||||||
|
|
||||||
|
let common = 0;
|
||||||
|
while (common < from.length && common < target.length && equal(from[common], target[common])) {
|
||||||
|
common++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||||
|
return result.join('/') || '.';
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const SECRET_VERSION = 2;
|
||||||
|
const SECRET_CIPHER = 'AES-256-GCM';
|
||||||
|
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
|
||||||
|
const SECRET_ITERATIONS = 600_000;
|
||||||
|
const SALT_BYTES = 16;
|
||||||
|
const NONCE_BYTES = 12;
|
||||||
|
|
||||||
|
interface SecretEnvelope {
|
||||||
|
v: number;
|
||||||
|
title?: string;
|
||||||
|
cipher: string;
|
||||||
|
kdf: string;
|
||||||
|
iterations: number;
|
||||||
|
salt: string;
|
||||||
|
nonce: string;
|
||||||
|
ct: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string, field: string): Uint8Array {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
|
||||||
|
throw new Error(`Invalid secret ${field}`);
|
||||||
|
}
|
||||||
|
const binary = atob(text);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticatedMetadata(envelope: SecretEnvelope): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
v: envelope.v,
|
||||||
|
title: envelope.title ?? 'Encrypted secret',
|
||||||
|
cipher: envelope.cipher,
|
||||||
|
kdf: envelope.kdf,
|
||||||
|
iterations: envelope.iterations,
|
||||||
|
salt: envelope.salt,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
|
||||||
|
return encoder.encode(authenticatedMetadata(envelope));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvelope(payload: string): SecretEnvelope {
|
||||||
|
let envelope: SecretEnvelope;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid secret payload');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
envelope?.v !== SECRET_VERSION ||
|
||||||
|
envelope.cipher !== SECRET_CIPHER ||
|
||||||
|
envelope.kdf !== SECRET_KDF ||
|
||||||
|
!Number.isInteger(envelope.iterations) ||
|
||||||
|
envelope.iterations < 100_000 ||
|
||||||
|
envelope.iterations > 5_000_000
|
||||||
|
) {
|
||||||
|
throw new Error('Unsupported secret payload');
|
||||||
|
}
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSecretTitle(payload: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
return typeof parsed?.title === 'string' && parsed.title.trim()
|
||||||
|
? parsed.title.trim()
|
||||||
|
: 'Encrypted secret';
|
||||||
|
} catch {
|
||||||
|
return 'Encrypted secret';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
||||||
|
if (!passphrase) throw new Error('Passphrase is required');
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(passphrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey'],
|
||||||
|
);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
|
||||||
|
if (!plaintext) throw new Error('Secret text is required');
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
||||||
|
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
||||||
|
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
|
||||||
|
const envelope: SecretEnvelope = {
|
||||||
|
v: SECRET_VERSION,
|
||||||
|
title: title.trim() || 'Encrypted secret',
|
||||||
|
cipher: SECRET_CIPHER,
|
||||||
|
kdf: SECRET_KDF,
|
||||||
|
iterations: SECRET_ITERATIONS,
|
||||||
|
salt: bytesToBase64(salt),
|
||||||
|
nonce: bytesToBase64(nonce),
|
||||||
|
ct: '',
|
||||||
|
};
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
|
||||||
|
key,
|
||||||
|
encoder.encode(plaintext),
|
||||||
|
);
|
||||||
|
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
|
||||||
|
return JSON.stringify(envelope, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const envelope = parseEnvelope(payload);
|
||||||
|
const salt = base64ToBytes(envelope.salt, 'salt');
|
||||||
|
const nonce = base64ToBytes(envelope.nonce, 'nonce');
|
||||||
|
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
|
||||||
|
const key = await deriveKey(passphrase, salt, envelope.iterations);
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
|
||||||
|
key,
|
||||||
|
stableBytes(ciphertext),
|
||||||
|
);
|
||||||
|
return decoder.decode(plaintext);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,3 +24,20 @@ export function formatDate(dateStr: string): string {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relative-date bucket label for grouping the notes list:
|
||||||
|
// Today / Yesterday / Previous 7 Days / Previous 30 Days / "June" (this year) / "June 2025".
|
||||||
|
// Buckets are contiguous, so a list already sorted by date groups by consecutive label.
|
||||||
|
export function dateBucketLabel(dateStr: string, now: Date = new Date()): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const dayMs = 86400000;
|
||||||
|
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||||
|
const today = startOfDay(now);
|
||||||
|
const day = startOfDay(d);
|
||||||
|
if (day >= today) return 'Today';
|
||||||
|
if (day >= today - dayMs) return 'Yesterday';
|
||||||
|
if (day >= today - 7 * dayMs) return 'Previous 7 Days';
|
||||||
|
if (day >= today - 30 * dayMs) return 'Previous 30 Days';
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'long' });
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|||||||