Compare commits

...
43 Commits
Author SHA1 Message Date
Yuri Karamian 2983e3db29 Merge branch 'main' into 'main'
Merge branch 'fix/sort-theme-list-alphabetically' into 'main'

Closes #250

See merge request ArkHost/HelixNotes!7
2026-09-17 12:30:52 +00:00
Shawn cc7c9e2b49 Merge branch HelixNotes:main into main 2026-09-15 16:09:56 +00:00
Shawn 2240231f61 Merge branch 'fix/sort-theme-list-alphabetically' into 'main'
fix: sort theme list alphabetically

See merge request locodarwin/HelixNotes!1
2026-09-15 15:36:44 +00:00
Yuri Karamian b2d0c7175e test: cover built-in theme preset ordering 2026-09-15 15:04:51 +02:00
Shawn Olson 1bc0026731 fix: sort theme list alphabetically 2026-09-14 14:42:06 -07:00
Yuri Karamian 41c0e4c993 fix: preserve Linux window title and configuration 2026-09-10 16:50:54 +02:00
Yuri Karamian 34a0f9efa3 fix: keep mobile navigation visible with collapsed desktop sidebar 2026-09-06 22:46:11 +02:00
Yuri Karamian 9d92489586 Merge branch 'fix/macos-escape-overlay-fullscreen' into 'main'
Prevent Escape from exiting fullscreen when closing overlays

See merge request ArkHost/HelixNotes!4
2026-09-04 13:00:47 +00:00
Vijay c45efb60ab Prevent Escape from exiting fullscreen when closing overlays 2026-09-04 13:00:46 +00:00
Yuri Karamian 0d11fd01dd fix: preserve hidden note title across editor modes 2026-09-04 14:46:30 +02:00
Yuri Karamian 0aaa05ce5c Merge branch 'fix/context-menu-paste-image' into 'main'
fix: paste images from the editor context menu

See merge request ArkHost/HelixNotes!6
2026-09-04 08:54:04 +00:00
Sridhar 77e92ef25c fix: paste images from the editor context menu 2026-09-04 08:54:04 +00:00
Yuri Karamian d7e4be7f3a Merge branch 'fix/webkitgtk-clipboard-image-paste' into 'main'
fix: paste images copied from a browser on WebKitGTK

Closes #245

See merge request ArkHost/HelixNotes!5
2026-09-03 19:07:34 +00:00
Sridhar 2aa572dcaa fix: paste images copied from a browser on WebKitGTK 2026-09-03 19:07:34 +00:00
Yuri Karamian 32f270ce9e Update v1.3.5 download documentation 2026-08-27 00:37:30 +02:00
Yuri Karamian a15b267614 Add scroll font size preference and bump v1.3.5 2026-08-26 17:16:16 +02:00
Yuri Karamian ce48d77cd8 fix: stop wiki links extending after autocomplete 2026-08-23 15:38:57 +02:00
Yuri Karamian 221625aabd fix: override vulnerable cookie dependency 2026-08-17 21:17:53 +02:00
Yuri Karamian 1bd4cd8138 chore: set Rust MSRV to 1.88 2026-08-17 21:15:11 +02:00
Yuri Karamian dd748776b2 test: add local verification command 2026-08-17 21:08:31 +02:00
Yuri Karamian 0391a60c1b fix: block private image proxy requests 2026-08-17 21:06:50 +02:00
Yuri Karamian 33d77cf2fd fix: restrict external file access 2026-08-17 21:02:05 +02:00
Yuri Karamian 81e4e26290 fix: restrict asset protocol to vaults 2026-08-17 20:14:34 +02:00
Yuri Karamian 111d819f92 fix: improve editor accessibility 2026-08-17 19:55:00 +02:00
Yuri Karamian c37818de1a fix: improve settings accessibility 2026-08-17 19:49:05 +02:00
Yuri Karamian 95dab8cf74 fix: improve note list accessibility 2026-08-17 19:46:50 +02:00
Yuri Karamian 62365f52b1 fix: mark resize handles presentational 2026-08-17 19:45:19 +02:00
Yuri Karamian 6eb1ec6eb2 fix: improve sidebar interactions 2026-08-17 19:44:35 +02:00
Yuri Karamian 527c6a7a91 fix: label storage location choices 2026-08-17 19:43:31 +02:00
Yuri Karamian 45e3c389e4 fix: handle info overlay clicks 2026-08-17 19:42:47 +02:00
Yuri Karamian 9ccbf3aff5 fix: make layout references reactive 2026-08-17 19:31:21 +02:00
Yuri Karamian 695664913a fix: make graph state reactive 2026-08-17 19:28:48 +02:00
Yuri Karamian d0fb87cf3a style: format Rust code 2026-08-17 19:27:05 +02:00
Yuri Karamian ac7c79af04 fix: resolve Rust lints 2026-08-17 19:24:32 +02:00
Yuri Karamian 1ebf2a9843 fix: resolve type errors 2026-08-17 19:04:21 +02:00
Yuri Karamian 44dcfbe24a fix: tighten app permissions 2026-08-17 18:57:56 +02:00
Yuri Karamian 00196ecedb fix: protect config files 2026-08-17 18:53:53 +02:00
Yuri Karamian c4d2d1b2d1 fix: validate version paths 2026-08-17 18:52:40 +02:00
Yuri Karamian 3a9a5129c4 fix: restrict backup paths 2026-08-17 18:50:57 +02:00
Yuri Karamian a964ee1930 fix: restrict vault paths 2026-08-17 18:49:32 +02:00
Yuri Karamian 2d57577338 fix: update Rust dependencies 2026-08-17 18:43:11 +02:00
Yuri Karamian 206faed113 fix: update frontend dependencies 2026-08-17 18:37:57 +02:00
Yuri Karamian b3c292d6e2 test(paths): cover cross-platform asset resolution 2026-08-17 18:35:26 +02:00
46 changed files with 3159 additions and 1868 deletions
+24 -14
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![Latest Release](https://img.shields.io/badge/release-v1.3.5-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -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.
No cloud, no lock-in.
## Download (v1.3.4)
## Download (v1.3.5)
### Linux
@@ -18,7 +18,7 @@ No cloud, no lock-in.
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
#### Distro-specific packages
@@ -108,26 +108,28 @@ sudo eopkg it helixnotes
#### Manual package downloads
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
- [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
### Windows
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
>
> ```bash
> xattr -cr /Applications/HelixNotes.app
> ```
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
```bash
xattr -cr /Applications/HelixNotes.app
```
### Android
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
---
@@ -166,7 +168,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
### Prerequisites
- [Rust](https://rustup.rs/) (1.77+)
- [Rust](https://rustup.rs/) (1.88+)
- [Node.js](https://nodejs.org/) (18+)
- [pnpm](https://pnpm.io/)
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
@@ -178,6 +180,14 @@ pnpm install
pnpm tauri dev
```
### Verification
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
```bash
pnpm verify
```
### Production Build
```bash
+14 -10
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.4",
"version": "1.3.5",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -11,6 +11,10 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
@@ -18,22 +22,22 @@
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.10.0",
"svelte": "^5.49.2",
"@types/node": "26.2.0",
"svelte": "^5.56.9",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.6"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
@@ -62,11 +66,11 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
"markdown-it": "^14.3.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
"mermaid": "^11.16.1"
}
}
+598 -692
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,2 +1,4 @@
allowBuilds:
esbuild: true
overrides:
cookie: 0.7.2
+67 -275
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -138,12 +127,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -346,18 +329,6 @@ dependencies = [
"crunchy",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -389,29 +360,6 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
dependencies = [
"borsh-derive",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -439,40 +387,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -624,6 +538,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -738,6 +663,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -768,9 +702,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1314,12 +1248,6 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1796,9 +1725,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1806,7 +1732,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"ahash",
"allocator-api2",
]
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
dependencies = [
"arboard",
"chrono",
@@ -1862,7 +1788,7 @@ dependencies = [
"notify",
"objc2-app-kit",
"png 0.17.16",
"quick-xml 0.36.2",
"quick-xml",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -2554,9 +2480,6 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru"
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "mac"
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"time",
]
@@ -3576,26 +3499,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -3657,14 +3551,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.1",
"lru-slab",
"rand 0.9.2",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -3705,12 +3600,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3722,7 +3611,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@@ -3738,12 +3627,13 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.1",
]
[[package]]
@@ -3766,16 +3656,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3796,12 +3676,9 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
@@ -3831,6 +3708,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3935,15 +3821,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -4063,35 +3940,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -4102,22 +3950,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4327,12 +4159,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -4600,12 +4426,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -5007,17 +4827,11 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -5214,12 +5028,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
@@ -5964,12 +5777,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5988,12 +5795,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -6246,12 +6047,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.38.4",
"quick-xml",
"quote",
]
@@ -7007,15 +6808,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
+10 -3
View File
@@ -1,11 +1,15 @@
[package]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.88"
[lints.clippy]
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
uninlined_format_args = "allow"
[lib]
name = "app_lib"
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
@@ -38,7 +45,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
quick-xml = "0.41"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}
+1 -27
View File
@@ -24,32 +24,6 @@
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:default",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-watch",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "/**" }]
},
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://**" },
{ "url": "http://**" },
{ "url": "mailto:*" }
]
},
"opener:allow-reveal-item-in-dir"
"fs:allow-read-file"
]
}
+28 -16
View File
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
#[allow(clippy::too_many_arguments)]
pub fn ai_request(
app: AppHandle,
provider: String,
@@ -22,7 +23,11 @@ pub fn ai_request(
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key.as_str())
};
let result = match provider.as_str() {
"openai" => {
stream_openai(
@@ -98,7 +103,10 @@ pub fn ai_request(
/// 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()
b.strip_suffix("/v1")
.unwrap_or(b)
.trim_end_matches('/')
.to_string()
}
async fn stream_anthropic(
@@ -268,9 +276,7 @@ async fn stream_openai(
body["temperature"] = json!(0.7);
}
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
@@ -380,7 +386,11 @@ pub async fn test_connection(
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key)
};
match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({
"model": model,
token_key: 20,
"messages": [
let body = json!({
"model": model,
token_key: 20,
"messages": [
{
"role": "user",
"content": "Hi"
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
]
});
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
+111
View File
@@ -0,0 +1,111 @@
use std::path::Path;
use tauri::{AppHandle, Manager, Runtime};
/// Grants asset-protocol access only to a vault selected by the user.
/// `.helixnotes` is added explicitly because Unix glob matching excludes hidden paths.
pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) -> Result<(), String> {
// Keep the canonical native path: Tauri's scope normalizes Unix paths plus
// Windows drive, verbatim, and UNC prefixes when matching asset requests.
let vault = std::fs::canonicalize(vault_path).map_err(|error| {
format!(
"Failed to resolve vault asset path '{}': {error}",
vault_path.display()
)
})?;
let scope = app.asset_protocol_scope();
scope
.allow_directory(&vault, true)
.map_err(|error| error.to_string())?;
scope
.allow_directory(vault.join(".helixnotes"), true)
.map_err(|error| error.to_string())
}
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
pub fn allow_external_note_assets<R: Runtime>(
app: &AppHandle<R>,
note_path: &Path,
) -> Result<(), String> {
let note = std::fs::canonicalize(note_path).map_err(|error| {
format!(
"Failed to resolve external note path '{}': {error}",
note_path.display()
)
})?;
let parent = note
.parent()
.ok_or_else(|| "External note has no parent directory".to_string())?;
app.asset_protocol_scope()
.allow_directory(parent, true)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn test_directory(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("helixnotes-{name}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn static_asset_scope_does_not_expose_the_filesystem() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let scope = &config["app"]["security"]["assetProtocol"]["scope"];
assert_eq!(scope["allow"], serde_json::json!([]));
assert_eq!(scope["deny"], serde_json::json!([]));
assert_eq!(scope["requireLiteralLeadingDot"], true);
}
#[test]
fn runtime_scope_allows_only_the_selected_vault() {
let root = test_directory("asset-scope");
let vault = root.join("Vault [Cross Platform]");
let attachments = vault.join(".helixnotes").join("attachments");
fs::create_dir_all(&attachments).unwrap();
let note_image = vault.join("images").join("note image.png");
fs::create_dir_all(note_image.parent().unwrap()).unwrap();
fs::write(&note_image, b"image").unwrap();
let attachment = attachments.join("attachment.png");
fs::write(&attachment, b"attachment").unwrap();
let outside = root.join("outside.png");
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_vault_assets(app.handle(), &vault).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note_image));
assert!(scope.is_allowed(&attachment));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
let root = test_directory("external-note-assets");
let note_dir = root.join("shared note");
let assets = note_dir.join("images");
fs::create_dir_all(&assets).unwrap();
let note = note_dir.join("note.md");
let image = assets.join("image.png");
let outside = root.join("outside.png");
fs::write(&note, b"note").unwrap();
fs::write(&image, b"image").unwrap();
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_external_note_assets(app.handle(), &note).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note));
assert!(scope.is_allowed(&image));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
}
+48 -15
View File
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "zip") {
if path.extension().is_some_and(|ext| ext == "zip") {
let filename = path
.file_name()
.unwrap_or_default()
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
Ok(entries)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = Path::new(backup_path);
if !backup.exists() {
return Err("Backup file does not exist".to_string());
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
if backup.parent() != Some(backup_dir.as_path())
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|| !backup.is_file()
{
return Err(
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
);
}
Ok(backup)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(
vault_path: &str,
backup_dir: &Path,
backup_path: &str,
) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = validated_backup_file(backup_dir, backup_path)?;
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
let mut archive =
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
}
/// Delete a single backup file
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
let path = Path::new(backup_path);
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
let path = validated_backup_file(backup_dir, backup_path)?;
fs::remove_file(path).map_err(|error| error.to_string())
}
/// Remove old backups keeping only the newest `max_count`
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
if backups.len() as u32 > max_count {
let to_remove = backups.split_off(max_count as usize);
for entry in to_remove {
delete_backup(&entry.path)?;
delete_backup(backup_dir, &entry.path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::delete_backup;
use std::fs;
use uuid::Uuid;
#[test]
fn delete_backup_rejects_files_outside_backup_directory() {
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
let backup_dir = root.join("backups");
let outside = root.join("outside.zip");
fs::create_dir_all(&backup_dir).unwrap();
fs::write(&outside, "must survive").unwrap();
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
assert!(result.is_err());
assert!(outside.exists());
fs::remove_dir_all(root).unwrap();
}
}
+549 -226
View File
File diff suppressed because it is too large Load Diff
+60 -22
View File
@@ -1,22 +1,36 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::types::VersionEntry;
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
let mut components = Path::new(value).components();
if value.is_empty()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(format!("Invalid {label}"));
}
Ok(value)
}
/// Directory: .helixnotes/history/<note-id>/
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
Path::new(vault_path)
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
Ok(Path::new(vault_path)
.join(".helixnotes")
.join("history")
.join(note_id)
.join(safe_path_component(note_id, "note ID")?))
}
/// Save a version snapshot if enough time has passed since the last one.
/// Minimum interval: 5 minutes between snapshots.
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id);
let Ok(dir) = history_dir(vault_path, note_id) else {
log::warn!("Skipping history snapshot with an invalid note ID");
return;
};
// Check if we should create a snapshot (5 min cooldown)
if let Ok(entries) = fs::read_dir(&dir) {
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
}
/// Force-create a version snapshot, bypassing the cooldown.
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id);
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("Failed to create history dir: {}", e);
return;
}
pub fn force_snapshot(
vault_path: &str,
note_id: &str,
raw_content: &str,
max_versions: u32,
) -> Result<(), String> {
let dir = history_dir(vault_path, note_id)?;
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let filename = format!("{}.md", timestamp);
let path = dir.join(&filename);
if let Err(e) = fs::write(&path, raw_content) {
eprintln!("Failed to write version snapshot: {}", e);
return;
}
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
if max_versions > 0 {
let _ = prune_versions(&dir, max_versions);
prune_versions(&dir, max_versions)?;
}
Ok(())
}
/// List all version snapshots for a note, newest first.
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
let dir = history_dir(vault_path, note_id);
let dir = history_dir(vault_path, note_id)?;
if !dir.exists() {
return Ok(Vec::new());
}
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") {
if path.extension().is_some_and(|ext| ext == "md") {
let filename = path
.file_stem()
.unwrap_or_default()
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
/// Get the raw content of a specific version.
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
safe_path_component(timestamp, "version timestamp")?;
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
let filename = if let Some(t_pos) = timestamp.find('T') {
let date_part = &timestamp[..t_pos];
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
let time_dashes = time_part.replace(':', "-");
format!("{}.md", format!("{}T{}", date_part, time_dashes))
format!("{date_part}T{time_dashes}.md")
} else {
format!("{}.md", timestamp)
};
let path = history_dir(vault_path, note_id).join(&filename);
let path = history_dir(vault_path, note_id)?.join(&filename);
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
}
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
// Sort by name (timestamps sort lexicographically) - newest last
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::{get_version, list_versions};
use std::fs;
use uuid::Uuid;
#[test]
fn rejects_history_path_traversal() {
let vault =
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
let metadata = vault.join(".helixnotes");
let escaped_history = metadata.join("escaped");
fs::create_dir_all(&escaped_history).unwrap();
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
fs::write(metadata.join("secret.md"), "secret").unwrap();
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
fs::remove_dir_all(vault).unwrap();
}
}
+245
View File
@@ -0,0 +1,245 @@
use reqwest::{blocking::Client, redirect::Policy, Url};
use std::io::Read;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
pub struct ImageResponse {
pub content_type: String,
pub body: Vec<u8>,
}
pub enum ProxyError {
Invalid(String),
Blocked(String),
Fetch(String),
}
impl ProxyError {
pub fn status(&self) -> u16 {
match self {
Self::Invalid(_) => 400,
Self::Blocked(_) => 403,
Self::Fetch(_) => 502,
}
}
pub fn message(&self) -> &str {
match self {
Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message,
}
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
!(a == 0
|| a == 10
|| a == 127
|| a >= 224
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113))
}
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() {
return false;
}
let first = ip.segments()[0];
(0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8]
}
fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_ipv4(ip),
IpAddr::V6(ip) => is_public_ipv6(ip),
}
}
fn normalized_host(host: &str) -> String {
host.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn validate_url(raw_url: &str) -> Result<Url, ProxyError> {
let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ProxyError::Invalid(
"Only HTTP and HTTPS images are supported".into(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ProxyError::Invalid(
"Image URLs must not contain credentials".into(),
));
}
let expected_port = if url.scheme() == "https" { 443 } else { 80 };
if url.port_or_known_default() != Some(expected_port) {
return Err(ProxyError::Blocked("Non-standard image URL port".into()));
}
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
if host.ends_with('.') {
return Err(ProxyError::Invalid(
"Image URL host must not have a trailing dot".into(),
));
}
let normalized = normalized_host(host);
if normalized == "localhost" || normalized.ends_with(".localhost") {
return Err(ProxyError::Blocked(
"Local image hosts are not allowed".into(),
));
}
if let Ok(ip) = normalized.parse::<IpAddr>() {
if !is_public_ip(ip) {
return Err(ProxyError::Blocked(
"Private image addresses are not allowed".into(),
));
}
}
Ok(url)
}
fn resolve_public_host(url: &Url) -> Result<(String, Vec<SocketAddr>), ProxyError> {
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
let host = normalized_host(host);
let port = url
.port_or_known_default()
.ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?;
let addresses: Vec<_> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))?
.collect();
if addresses.is_empty() {
return Err(ProxyError::Fetch(
"Image host resolved to no addresses".into(),
));
}
if addresses.iter().any(|address| !is_public_ip(address.ip())) {
return Err(ProxyError::Blocked(
"Image host resolves to a private address".into(),
));
}
Ok((host, addresses))
}
fn image_client(host: &str, addresses: &[SocketAddr]) -> Result<Client, ProxyError> {
let redirect_host = host.to_string();
Client::builder()
.timeout(Duration::from_secs(30))
.redirect(Policy::custom(move |attempt| {
let redirected_elsewhere = match attempt.url().host_str() {
Some(host) => normalized_host(host) != redirect_host,
None => true,
};
if attempt.previous().len() >= 5
|| redirected_elsewhere
|| validate_url(attempt.url().as_str()).is_err()
{
attempt.stop()
} else {
attempt.follow()
}
}))
.resolve_to_addrs(host, addresses)
.build()
.map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}")))
}
pub fn fetch(raw_url: &str) -> Result<ImageResponse, ProxyError> {
let url = validate_url(raw_url)?;
let (host, addresses) = resolve_public_host(&url)?;
let client = image_client(&host, &addresses)?;
let mut response = client
.get(url)
.send()
.map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?;
if !response.status().is_success() {
return Err(ProxyError::Fetch(format!(
"Image server returned {}",
response.status()
)));
}
if response
.content_length()
.is_some_and(|length| length > MAX_IMAGE_BYTES)
{
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
if !content_type.to_ascii_lowercase().starts_with("image/") {
return Err(ProxyError::Blocked(
"Remote resource is not an image".into(),
));
}
let mut body = Vec::new();
response
.by_ref()
.take(MAX_IMAGE_BYTES + 1)
.read_to_end(&mut body)
.map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?;
if body.len() as u64 > MAX_IMAGE_BYTES {
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
Ok(ImageResponse { content_type, body })
}
#[cfg(test)]
mod tests {
use super::{is_public_ip, validate_url};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn blocks_local_and_private_networks() {
for ip in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
"fc00::1".parse().unwrap(),
"fe80::1".parse().unwrap(),
] {
assert!(!is_public_ip(ip), "{ip}");
}
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
assert!(is_public_ip(
"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
));
}
#[test]
fn validates_only_public_web_image_urls() {
for url in [
"file:///etc/passwd",
"http://localhost/image.png",
"http://127.0.0.1/image.png",
"http://[::1]/image.png",
"http://example.com:8080/image.png",
"https://example.com./image.png",
"https://user:pass@example.com/image.png",
] {
assert!(validate_url(url).is_err(), "{url}");
}
assert!(validate_url("https://example.com/image.png").is_ok());
assert!(validate_url("http://93.184.216.34/image.png").is_ok());
}
}
+57 -65
View File
@@ -1,7 +1,9 @@
mod ai;
mod asset_scope;
mod backup;
mod commands;
mod history;
mod image_proxy;
mod search;
mod state;
mod sync;
@@ -11,6 +13,7 @@ mod vault;
use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{
@@ -56,6 +59,16 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.on_webview_event(|webview, event| {
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
let scope = webview.fs_scope();
for path in paths {
if path.is_file() {
let _ = scope.allow_file(path);
}
}
}
})
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
@@ -93,6 +106,20 @@ pub fn run() {
});
}
let active_vault = app
.state::<AppState>()
.config
.lock()
.ok()
.and_then(|config| config.active_vault.clone());
if let Some(vault_path) = active_vault {
if let Err(error) =
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
{
log::warn!("Failed to restore the vault asset scope: {error}");
}
}
#[cfg(desktop)]
if show_tray {
setup_tray(app)?;
@@ -112,6 +139,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ =
asset_scope::allow_external_note_assets(app.handle(), &resolved);
}
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
@@ -137,6 +172,7 @@ pub fn run() {
commands::export_custom_theme,
commands::import_custom_themes,
commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
@@ -190,6 +226,7 @@ pub fn run() {
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::reveal_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
@@ -217,72 +254,21 @@ pub fn run() {
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
let response = match image_proxy::fetch(&external_url) {
Ok(image) => tauri::http::Response::builder()
.status(200)
.header("Content-Type", image.content_type)
.header("Access-Control-Allow-Origin", "*")
.header("X-Content-Type-Options", "nosniff")
.body(image.body),
Err(error) => {
log::warn!("Blocked external image request: {}", error.message());
tauri::http::Response::builder()
.status(400)
.status(error.status())
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
responder.respond(response.expect("valid image proxy response"));
});
});
@@ -309,6 +295,13 @@ pub fn run() {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ = asset_scope::allow_external_note_assets(app, &resolved);
}
let _ = app.emit("open-file", resolved_str.to_string());
}
}
@@ -350,9 +343,9 @@ pub fn run() {
let _ = window.hide();
}
}
tauri::WindowEvent::Destroyed => {
tauri::WindowEvent::Destroyed
// When main window is destroyed, close all note windows
if window.label() == "main" {
if window.label() == "main" => {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
@@ -360,7 +353,6 @@ pub fn run() {
}
}
}
}
_ => {}
}
});
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
app_lib::run();
}
+14 -8
View File
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes());
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
let key: String = hasher.finalize()[..8]
.iter()
.map(|b| format!("{:02x}", b))
.collect();
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
}
@@ -235,7 +238,9 @@ impl SearchIndex {
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
TextAnalyzer::builder(CjkTokenizer)
.filter(LowerCaser)
.build(),
);
#[cfg(mobile)]
@@ -290,7 +295,7 @@ impl SearchIndex {
doc.add_text(self.path_field, &path_str);
doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" "));
doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc);
}
}
@@ -321,7 +326,7 @@ impl SearchIndex {
doc.add_text(self.path_field, path);
doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" "));
doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc);
writer.commit().map_err(|e| e.to_string())?;
@@ -341,7 +346,7 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher();
let fields = vec![self.title_field, self.body_field, self.tags_field];
let fields = [self.title_field, self.body_field, self.tags_field];
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
@@ -376,9 +381,10 @@ impl SearchIndex {
));
vec![(Occur::Should, exact)]
} else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let prefix: Box<dyn Query> =
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
field, term,
)]));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
+31 -8
View File
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
}
fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
s.trim()
.trim_start_matches("W/")
.trim_matches('"')
.to_string()
}
/// Percent-encode each path segment, keeping the `/` separators.
@@ -265,7 +268,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
return Err(format!(
"GET {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
}
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
}
@@ -279,7 +286,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
return Err(format!(
"PUT {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
}
Ok(resp
.headers()
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
if let Ok(decoded) = e.decode() {
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
buf.push_str(&text);
}
}
}
}
@@ -515,8 +528,14 @@ fn apply_changes(
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
let local_changed = match m {
Some(entry) => entry.local_hash != lf.hash,
None => true,
};
let remote_changed = match m {
Some(entry) => &entry.remote_etag != re,
None => true,
};
if !local_changed && !remote_changed {
new_m.files.insert(
key.clone(),
@@ -669,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
/// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
pub fn run_sync(
app: tauri::AppHandle,
vault: String,
cfg: WebdavConfig,
) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering;
let state = app.state::<AppState>();
+19 -4
View File
@@ -124,6 +124,8 @@ pub struct AppConfig {
pub accent_color: Option<String>,
#[serde(default)]
pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
@@ -296,6 +298,7 @@ impl Default for AppConfig {
system_dark_theme: default_system_dark_theme(),
accent_color: None,
font_size: None,
scroll_to_change_font_size: true,
font_family: None,
line_height: None,
ui_scale: None,
@@ -549,12 +552,24 @@ mod startup_view_tests {
assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("show_note_switcher");
value.as_object_mut().unwrap().remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(!config.show_note_switcher);
}
#[test]
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(config.scroll_to_change_font_size);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("scroll_to_change_font_size");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(config.scroll_to_change_font_size);
}
}
+5 -5
View File
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
"[{}]",
meta.tags
.iter()
.map(|t| format!("{}", t))
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(", ")
)
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
chars.next(); // skip '['
let mut depth = 1;
// Skip alt text
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if ch == '[' {
let mut link_text = String::new();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
+52 -38
View File
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
);
if is_embeddable {
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param };
let alt = if is_dimension_spec(alt_param) {
""
} else {
alt_param
};
result.links_converted += 1;
format!("![{}]({})", alt, link_target)
} else {
let display = if alt_param.is_empty() {
file_part.rsplit('/').next().unwrap_or(file_part)
} else if is_dimension_spec(alt_param) {
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part)
} else {
alt_param
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
let (file_part, anchor) = split_anchor(note_ref);
if file_part.is_empty() {
if let Some(a) = anchor {
let display = if display_param.is_empty() { a } else { display_param };
let display = if display_param.is_empty() {
a
} else {
display_param
};
result.links_converted += 1;
return format!("[{}](#{})", display, a);
}
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
.to_string();
content = after_links;
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted);
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted);
content = fix_md_image_refs(
&content,
&md_img_re,
vault,
note_dir,
&file_index,
&mut result.links_converted,
);
content = fix_md_link_refs(
&content,
&md_link_re,
vault,
note_dir,
&file_index,
&mut result.links_converted,
);
if result.links_converted > links_before {
changed = true;
@@ -231,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
mapping.insert(new_key, val);
renamed = true;
}
} else {
if mapping.remove(&old_key).is_some() {
renamed = true;
}
} else if mapping.remove(&old_key).is_some() {
renamed = true;
}
}
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
let tags = normalize_tags(&mapping);
let title = mapping
.get(&serde_yaml::Value::String("title".into()))
.get(serde_yaml::Value::String("title".into()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
});
let id = mapping
.get(&serde_yaml::Value::String("id".into()))
.get(serde_yaml::Value::String("id".into()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter()
.find_map(|key| {
mapping
.get(&serde_yaml::Value::String((*key).into()))
.get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str())
.and_then(|s| frontmatter::parse_date_flexible(s))
.and_then(frontmatter::parse_date_flexible)
})
.or_else(|| file_created(path))
.unwrap_or_else(Utc::now);
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter()
.find_map(|key| {
mapping
.get(&serde_yaml::Value::String((*key).into()))
.get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str())
.and_then(|s| frontmatter::parse_date_flexible(s))
.and_then(frontmatter::parse_date_flexible)
})
.or_else(|| file_modified(path))
.unwrap_or_else(Utc::now);
let pinned = mapping
.get(&serde_yaml::Value::String("pinned".into()))
.get(serde_yaml::Value::String("pinned".into()))
.and_then(|v| v.as_bool())
.unwrap_or(false);
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
let mut seen = HashSet::new();
for key in &["tags", "tag"] {
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) {
if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
for raw in yaml_value_to_strings(val) {
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok()
.and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
}
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
}
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
@@ -513,7 +527,9 @@ fn fix_md_link_refs(
let display = &caps[1];
let href = &caps[2];
let decoded = percent_decode(href);
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:")
if decoded.starts_with("http")
|| decoded.starts_with('/')
|| decoded.starts_with("data:")
|| decoded.starts_with('#')
{
return format!("[{}]({})", display, href);
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
.to_string()
}
fn move_attachments(
vault: &Path,
) -> Result<HashMap<String, String>, String> {
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
let attachments_dir = vault.join(".helixnotes").join("attachments");
let _ = std::fs::create_dir_all(&attachments_dir);
@@ -601,10 +615,7 @@ fn move_attachments(
Ok(moved)
}
fn rewrite_attachment_refs(
vault: &Path,
moved: &HashMap<String, String>,
) -> Result<(), String> {
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
@@ -686,7 +697,7 @@ fn cleanup_empty_dirs(root: &Path) {
.map(|e| e.path().to_path_buf())
.collect();
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
for dir in dirs {
let dir_str = dir.to_string_lossy();
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
s.chars()
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
}
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
@@ -907,7 +919,10 @@ mod tests {
extract_heading_title("# My Title\n\nBody"),
Some("My Title".to_string())
);
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string()));
assert_eq!(
extract_heading_title("\n\n# Spaced Title"),
Some("Spaced Title".to_string())
);
assert_eq!(extract_heading_title("Body without heading"), None);
assert_eq!(extract_heading_title("## Subheading"), None);
assert_eq!(extract_heading_title(""), None);
@@ -947,8 +962,7 @@ mod tests {
#[test]
fn test_normalize_tags_strips_hash() {
let mapping: serde_yaml::Mapping =
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let tags = normalize_tags(&mapping);
assert_eq!(tags, vec!["hashed"]);
}
+293 -92
View File
@@ -17,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".helixnotes")
}
fn canonicalize_path(path: &Path, label: &str) -> Result<PathBuf, String> {
fs::canonicalize(path).map_err(|error| format!("Invalid {label}: {error}"))
}
fn ensure_vault_content_path(
vault_path: &str,
requested_path: &Path,
allow_root: bool,
) -> Result<PathBuf, String> {
let vault = canonicalize_path(Path::new(vault_path), "vault path")?;
let requested = canonicalize_path(requested_path, "vault item path")?;
let metadata = vault.join(".helixnotes");
if !requested.starts_with(&vault)
|| requested.starts_with(&metadata)
|| (!allow_root && requested == vault)
{
return Err("Path must stay inside the active vault".to_string());
}
Ok(requested_path.to_path_buf())
}
fn ensure_vault_content_dir(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, true)?;
if !requested.is_dir() {
return Err("Vault destination is not a directory".to_string());
}
Ok(requested)
}
fn ensure_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
if !requested.is_file()
|| requested
.extension()
.and_then(|extension| extension.to_str())
!= Some("md")
{
return Err("Note path must point to a Markdown file".to_string());
}
Ok(requested)
}
fn ensure_readable_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
if let Ok(note) = ensure_note_path(vault_path, requested_path) {
return Ok(note);
}
let trashed_note = ensure_trash_entry(vault_path, requested_path)?;
if !trashed_note.is_file()
|| trashed_note
.extension()
.and_then(|extension| extension.to_str())
!= Some("md")
{
return Err("Note path must point to a Markdown file".to_string());
}
Ok(trashed_note)
}
fn ensure_notebook_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
if !requested.is_dir() {
return Err("Notebook path must point to a directory".to_string());
}
Ok(requested)
}
fn ensure_trash_entry(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let trash = canonicalize_path(&helixnotes_dir(vault_path).join("trash"), "trash path")?;
let requested = canonicalize_path(requested_path, "trash item path")?;
if requested == trash || !requested.starts_with(&trash) {
return Err("Path must be an item inside the active vault trash".to_string());
}
Ok(requested_path.to_path_buf())
}
fn safe_relative_path(path: &str) -> Result<&Path, String> {
let relative = Path::new(path);
if relative.as_os_str().is_empty()
|| !relative
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
{
return Err("Path must be a safe vault-relative path".to_string());
}
Ok(relative)
}
fn safe_child_name(name: &str) -> Result<&str, String> {
let mut components = Path::new(name).components();
if name.trim().is_empty()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err("Name must not contain path separators".to_string());
}
Ok(name)
}
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
let hn_dir = helixnotes_dir(vault_path);
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
@@ -140,7 +241,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
paths
.par_iter()
.map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let relative = path
.strip_prefix(root)
.unwrap_or(path)
@@ -191,7 +296,11 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
let entries: Vec<NotebookEntry> = paths
.par_iter()
.map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let relative = path
.strip_prefix(root)
.unwrap_or(path)
@@ -243,9 +352,15 @@ pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path);
ensure_vault_content_dir(vault_path, root)?;
let vault_root = Path::new(vault_path);
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
log::info!(
"scan_notes: vault={}, scan={}, exists={}",
vault_path,
scan_path,
root.exists()
);
if !root.exists() {
return Err("Path does not exist".to_string());
@@ -285,7 +400,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.collect();
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_key(|note| std::cmp::Reverse(note.meta.modified));
return Ok(notes);
}
@@ -301,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
} else {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.filter_entry(|e| {
!is_hidden(e.path()) && !e.path().starts_with(helixnotes_dir(vault_path))
})
.filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
@@ -313,7 +430,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect();
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
Ok(notes)
}
}
@@ -423,8 +540,18 @@ fn read_note_entry_from_str(
})
}
pub fn read_note(path: &str) -> Result<NoteContent, String> {
let p = Path::new(path);
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
let validated = ensure_readable_note_path(vault_path, Path::new(path))?;
read_note_content(&validated, path)
}
pub fn read_vault_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
let validated = ensure_note_path(vault_path, Path::new(path))?;
read_note_content(&validated, path)
}
fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteContent, String> {
let p = validated;
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p
.file_name()
@@ -454,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
}
Ok(NoteContent {
path: path.to_string(),
path: reported_path.to_string(),
meta,
content,
raw,
})
}
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
let path = ensure_note_path(vault_path, Path::new(path))?;
let mut updated_meta = meta.clone();
updated_meta.modified = Utc::now();
@@ -471,7 +599,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
}
// Read existing file to preserve unknown frontmatter fields
let existing = fs::read_to_string(path).unwrap_or_default();
let existing = fs::read_to_string(&path).unwrap_or_default();
let raw = if existing.is_empty() {
frontmatter::update_note_raw(&updated_meta, body)
} else {
@@ -487,14 +615,11 @@ pub fn create_note(
notebook_relative: Option<&str>,
title: &str,
) -> Result<NoteEntry, String> {
let dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(rel),
let requested_dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path),
};
if !dir.exists() {
return Err("Notebook directory does not exist".to_string());
}
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
let filename = sanitize_filename(title);
let mut file_path = dir.join(format!("{}.md", filename));
@@ -535,10 +660,8 @@ pub fn create_note(
}
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
let src = Path::new(path);
if !src.is_file() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(path))?;
let src = validated.as_path();
let parent = src
.parent()
@@ -672,7 +795,9 @@ pub fn create_daily_note(
"eu" => target_date.format("%d/%m/%Y").to_string(),
_ => {
let locale = get_system_locale();
target_date.format_localized("%B %d, %Y", locale).to_string()
target_date
.format_localized("%B %d, %Y", locale)
.to_string()
}
};
@@ -719,12 +844,12 @@ pub fn create_notebook(
parent_relative: Option<&str>,
name: &str,
) -> Result<NotebookEntry, String> {
let parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(rel),
let requested_parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path),
};
let dir_path = parent.join(name);
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
let dir_path = parent.join(safe_child_name(name)?);
if dir_path.exists() {
return Err("Notebook already exists".to_string());
}
@@ -748,10 +873,8 @@ pub fn create_notebook(
}
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
let src = Path::new(note_path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
let src = validated.as_path();
let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
@@ -771,10 +894,8 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
}
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
let src = Path::new(notebook_path);
if !src.exists() {
return Err("Notebook does not exist".to_string());
}
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
let src = validated.as_path();
let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
@@ -803,10 +924,8 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
}
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(path))?;
let src = validated.as_path();
// Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
@@ -839,7 +958,13 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
update_wikilinks_after_rename(
vault_path,
&old_path_str,
&new_path_str,
&old_title,
new_title,
);
Ok(new_path_str)
}
@@ -898,11 +1023,17 @@ fn update_wikilinks_after_rename(
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
if !path.is_file() {
continue;
}
let path_str = path.to_string_lossy();
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
// Skip the renamed note itself
if *path_str == *new_path { continue; }
if *path_str == *new_path {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
@@ -918,22 +1049,13 @@ fn update_wikilinks_after_rename(
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
}
// Path-based rules are always safe (paths are unique).
@@ -966,13 +1088,11 @@ fn update_wikilinks_after_rename(
}
}
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
let src = Path::new(path);
if !src.exists() {
return Err("Notebook does not exist".to_string());
}
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
let src = validated.as_path();
let new_path = src.parent().unwrap().join(new_name);
let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
if new_path.exists() {
return Err("A notebook with that name already exists".to_string());
}
@@ -981,13 +1101,12 @@ pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
Ok(new_path.to_string_lossy().to_string())
}
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
let src = Path::new(note_path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
let src = validated.as_path();
let dest_dir = Path::new(dest_notebook);
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
let dest_dir = validated_dest.as_path();
if !dest_dir.is_dir() {
return Err("Destination notebook does not exist".to_string());
}
@@ -999,13 +1118,16 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
Ok(dest.to_string_lossy().to_string())
}
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
let src = Path::new(notebook_path);
if !src.exists() || !src.is_dir() {
return Err("Notebook does not exist".to_string());
}
pub fn move_notebook(
vault_path: &str,
notebook_path: &str,
dest_parent: &str,
) -> Result<String, String> {
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
let src = validated.as_path();
let dest_parent_path = Path::new(dest_parent);
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
let dest_parent_path = validated_dest.as_path();
if !dest_parent_path.is_dir() {
return Err("Destination does not exist".to_string());
}
@@ -1048,7 +1170,10 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() {
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
return Ok(TrashContents {
notes: Vec::new(),
notebooks: Vec::new(),
});
}
let vault_root = Path::new(vault_path);
@@ -1067,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
.min_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
.filter(|e| {
e.path().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name
@@ -1080,7 +1208,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
};
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.map(DateTime::<Utc>::from)
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
name,
@@ -1091,8 +1219,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
}
}
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
Ok(TrashContents { notes, notebooks })
}
@@ -1101,15 +1229,17 @@ pub fn restore_note(
trash_path: &str,
dest_notebook: Option<&str>,
) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() {
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
let src = validated.as_path();
if !src.is_file() {
return Err("Trashed note does not exist".to_string());
}
let dest_dir = match dest_notebook {
let requested_dest = match dest_notebook {
Some(nb) => PathBuf::from(nb),
None => PathBuf::from(vault_path),
};
let dest_dir = ensure_vault_content_dir(vault_path, &requested_dest)?;
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
let filename = src.file_name().unwrap_or_default().to_string_lossy();
@@ -1132,15 +1262,18 @@ pub fn restore_note(
}
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() || !src.is_dir() {
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
let src = validated.as_path();
if !src.is_dir() {
return Err("Trashed notebook does not exist".to_string());
}
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
let meta_path = src
.with_extension("")
.with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
@@ -1156,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
name.to_string()
};
let dest = Path::new(vault_path).join(&relative);
let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
@@ -1170,7 +1303,8 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
}
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path);
let validated = ensure_trash_entry(vault_path, Path::new(path))?;
let p = validated.as_path();
let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
@@ -1431,8 +1565,9 @@ pub fn sanitize_filename(name: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
load_notebook_icons, scan_notebooks, set_notebook_icon,
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
scan_notebooks, set_notebook_icon,
};
use std::fs;
use uuid::Uuid;
@@ -1441,10 +1576,7 @@ mod tests {
fn compares_numeric_segments_anywhere_in_names() {
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
names.sort_by(|left, right| compare_natural_names(left, right));
assert_eq!(
names,
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
);
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
assert_eq!(
compare_natural_names("Class 02", "Class 2b"),
std::cmp::Ordering::Less
@@ -1592,6 +1724,75 @@ mod tests {
fs::remove_file(outside).unwrap();
}
#[test]
fn reads_markdown_notes_from_trash_without_allowing_external_files() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
let trashed_note = trash.join("20240101000000000_Note.md");
let outside = test_root.join("outside.md");
fs::create_dir_all(&trash).unwrap();
fs::write(&trashed_note, "---\ntitle: Note\n---\n\ntrashed").unwrap();
fs::write(&outside, "outside").unwrap();
assert!(read_note(&vault.to_string_lossy(), &trashed_note.to_string_lossy()).is_ok());
assert!(read_note(&vault.to_string_lossy(), &outside.to_string_lossy()).is_err());
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_permanent_deletion_outside_trash() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let outside = test_root.join("outside.md");
fs::create_dir_all(helixnotes_dir(&vault.to_string_lossy()).join("trash")).unwrap();
fs::write(&outside, "must survive").unwrap();
let result = permanent_delete(&vault.to_string_lossy(), &outside.to_string_lossy());
assert!(result.is_err());
assert_eq!(fs::read_to_string(&outside).unwrap(), "must survive");
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_notebook_creation_outside_vault() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
fs::create_dir_all(&vault).unwrap();
let result = create_notebook(&vault.to_string_lossy(), Some(".."), "escaped");
assert!(result.is_err());
assert!(!test_root.join("escaped").exists());
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_traversal_in_restored_notebook_metadata() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
let trashed_notebook = trash.join("20240101000000000_Notebook");
fs::create_dir_all(&trashed_notebook).unwrap();
fs::write(trash.join("20240101000000000_Notebook.meta"), "../escaped").unwrap();
let result = restore_notebook(
&vault.to_string_lossy(),
&trashed_notebook.to_string_lossy(),
);
assert!(result.is_err());
assert!(trashed_notebook.exists());
assert!(!test_root.join("escaped").exists());
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault =
@@ -1623,4 +1824,4 @@ mod tests {
fs::remove_dir_all(vault).unwrap();
}
}
}
+7 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.4",
"version": "1.3.5",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
@@ -24,10 +24,14 @@
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": {
"enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"]
"scope": {
"allow": [],
"deny": [],
"requireLiteralLeadingDot": true
}
}
}
},
+8
View File
@@ -2,7 +2,15 @@
"app": {
"windows": [
{
"title": "HelixNotes",
"label": "main",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 500,
"resizable": true,
"fullscreen": false,
"decorations": false,
"visible": false
}
]
+9 -1
View File
@@ -71,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size });
}
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
return invoke("set_scroll_to_change_font_size", { enabled });
}
export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family });
}
@@ -185,7 +189,7 @@ export async function getNoteSwitcherTitles(
return invoke("get_note_switcher_titles", { recentPaths });
}
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
return invoke("get_graph_data");
}
@@ -364,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path });
}
export async function revealFile(path: string): Promise<void> {
return invoke("reveal_file", { path });
}
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
+9 -4
View File
@@ -81,9 +81,9 @@
return null;
}
let sidebar: Sidebar;
let noteList: NoteList;
let editor: Editor;
let sidebar = $state<Sidebar>();
let noteList = $state<NoteList>();
let editor = $state<Editor>();
let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') {
@@ -578,6 +578,11 @@
}
if (e.key === 'Escape') {
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
if (!dismissesAppUi) return;
e.preventDefault();
e.stopPropagation();
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false;
@@ -829,7 +834,7 @@
<!-- Mobile Header -->
<div class="mobile-header">
{#if $mobileView !== 'sidebar'}
<button class="mobile-header-btn" onclick={mobileBack}>
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
+9 -4
View File
@@ -164,10 +164,15 @@
}
});
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showCommandPalette = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
$showCommandPalette = false;
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
} else if (e.key === 'ArrowUp') {
@@ -194,7 +199,7 @@
{#if $showCommandPalette}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}>
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="palette-input-wrapper">
+206 -219
View File
@@ -54,6 +54,8 @@
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { clearFormatting } from '$lib/editor/clearFormatting';
import { serializeInlineMarkdown } from '$lib/editor/markdown';
import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
@@ -116,9 +118,7 @@
let hasPendingBlobs = false;
let lastSourceMode = $sourceMode;
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
let titleWasStripped = false;
let strippedTitle = '';
let strippedHeadingPrefix = '';
let hiddenTitleHeading: HiddenTitleHeading | null = null;
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
let taskRevealElement: HTMLElement | null = null;
let taskRevealRequest = 0;
@@ -132,6 +132,8 @@
let highlightDropdown = $state(false);
let alignDropdown = $state(false);
let insertDropdown = $state(false);
let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 });
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
@@ -306,6 +308,22 @@
let editorState = $state(0);
let editorStateRaf = 0; // RAF handle for batching toolbar updates
function isEditorActive(
nameOrAttributes: string | Record<string, unknown>,
attributes?: Record<string, unknown>,
): boolean {
void editorState;
if (!editor) return false;
return typeof nameOrAttributes === 'string'
? editor.isActive(nameOrAttributes, attributes)
: editor.isActive(nameOrAttributes);
}
function getEditorAttributes(name: string): Record<string, unknown> {
void editorState;
return editor?.getAttributes(name) ?? {};
}
// AI
let aiMenu = $state<{ x: number; y: number } | null>(null);
let aiLoading = $state(false);
@@ -470,8 +488,6 @@
});
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 });
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string; alt: string } | null>(null);
let imageViewer = $state<{ src: string; alt: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null);
@@ -2531,10 +2547,7 @@
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
if (!editor || !wikiLinkMenu) return;
const { from } = wikiLinkMenu;
// Delete the [[ trigger and query text
const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from, to }).run();
// Insert the wiki-link mark
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
const displayText = entry.title;
let titleAttr = originalRef || entry.title;
@@ -2546,17 +2559,16 @@
titleAttr = relPath + anchor;
}
}
tick().then(() => {
if (!editor) return;
editor.chain().focus()
.insertContent({
type: 'text',
text: displayText,
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
})
.run();
});
const transaction = replaceWithWikiLink(
editor.state,
from,
to,
displayText,
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
);
closeWikiLinkMenu();
editor.view.dispatch(transaction);
editor.view.focus();
}
function executeWikiLinkCommand(index: number) {
@@ -2743,21 +2755,13 @@
closeWikiLinkMenu();
tick().then(() => {
if (!editor) return;
// Use a single ProseMirror transaction to replace [[query] with the
// wiki-link and clear stored marks atomically, preventing the inclusive
// mark from bleeding into subsequent text. Do NOT call deleteRange first
// — that would shift positions and make menuFrom/curTo invalid here.
const { tr, schema } = editor.view.state;
const wikiLinkMark = schema.marks.wikiLink.create({
title: noteRef,
path: '',
aliased: display !== noteRef,
});
const textNode = schema.text(display, [wikiLinkMark]);
tr.replaceWith(menuFrom, curTo, textNode);
tr.setSelection(TextSelection.create(tr.doc, menuFrom + display.length));
tr.setStoredMarks([]);
editor.view.dispatch(tr);
editor.view.dispatch(replaceWithWikiLink(
editor.state,
menuFrom,
curTo,
display,
{ title: noteRef, path: '', aliased: display !== noteRef },
));
});
}
} else {
@@ -3398,44 +3402,13 @@
}
function stripTitleH1(md: string): string {
const title = $activeNote?.meta.title;
if (!$appConfig?.hide_title_in_body || !title) {
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
}
// Find the first non-empty line
const lines = md.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
// Check if it's a heading (any level) matching the note title
// Normalize: lowercase, collapse whitespace, strip common separators (- - _)
const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' ');
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalize(match[2]) === normalize(title)) {
titleWasStripped = true;
strippedTitle = title.trim();
strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##")
lines.splice(i, 1);
// Also remove a trailing blank line after the heading if present
if (i < lines.length && lines[i].trim() === '') {
lines.splice(i, 1);
}
return lines.join('\n');
}
break; // First non-empty line isn't a matching heading, stop
}
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
hiddenTitleHeading = result.hiddenTitle;
return result.markdown;
}
function restoreTitleH1(md: string): string {
if (!titleWasStripped || !strippedTitle) return md;
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
return restoreTitleHeading(md, hiddenTitleHeading);
}
function editorToMarkdown(): string {
@@ -3681,6 +3654,27 @@
requestAnimationFrame(() => el.focus());
}
function closeFromOverlay(event: MouseEvent, close: () => void) {
if (event.target === event.currentTarget) close();
}
function closeOnEscape(event: KeyboardEvent, close: () => void) {
if (event.key === 'Escape') close();
}
function closeFormattingDropdowns() {
headingDropdown = false;
colorDropdown = false;
highlightDropdown = false;
tablePickerOpen = false;
alignDropdown = false;
insertDropdown = false;
}
function handleFormattingBarClick(event: MouseEvent) {
if (!(event.target as Element).closest('.fmt-dropdown')) closeFormattingDropdowns();
}
// ── In-note search functions ──
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -4970,13 +4964,25 @@
}
async function ctxPaste() {
if (!editor) return;
// insertClipboardImage() saves the attachment before inserting it, so an
// unguarded paste here would leave an orphan file behind when the editor
// cannot accept the insertion.
if (!editor || $readOnly || $viewerNote) { closeTextContextMenu(); return; }
try {
const text = await navigator.clipboard.readText();
if (text) editor.chain().focus().insertContent(text).run();
if (text) {
editor.chain().focus().insertContent(text).run();
closeTextContextMenu();
return;
}
} catch (e) {
console.error('Paste failed:', e);
}
// readText() only reads text/plain. An image copied from a browser has none,
// so pasting an image from this menu used to do nothing at all. Fall back to
// the same native clipboard reader the paste handler uses; it is a no-op when
// the clipboard holds no image.
await insertClipboardImage();
closeTextContextMenu();
}
@@ -5603,17 +5609,20 @@
}
return true;
}
// WebKitGTK fallback (bug #218519): older WebKitGTK versions return
// empty DataTransferItemList for image pastes. Detect this and read
// the image directly from the system clipboard via Rust/arboard.
if (items.length === 0) {
const hasText = event.clipboardData!.getData('text/plain');
const hasHtml = event.clipboardData!.getData('text/html');
if (!hasText && !hasHtml) {
event.preventDefault();
insertClipboardImage();
return true;
}
// WebKitGTK fallback (bug #218519): WebKitGTK never exposes a clipboard image
// through DataTransfer, so the loop above cannot fire on Linux and this is the
// only path that can work there. The guard used to also require an empty
// DataTransferItemList and no text/html, which no real image copy satisfies:
// Chromium's "Copy image" writes an <img> HTML fragment alongside the bitmap
// (but no text/plain), so the image was dropped and its remote URL pasted
// instead. Key off text/plain, and accept HTML that is just an <img>.
const hasText = event.clipboardData!.getData('text/plain');
const html = event.clipboardData!.getData('text/html');
const htmlIsBareImage = /^\s*(?:<meta[^>]*>\s*)?<img\b[^>]*>\s*$/i.test(html);
if (!hasText && (!html || htmlIsBareImage)) {
event.preventDefault();
insertClipboardImage();
return true;
}
return false;
}
@@ -5753,7 +5762,7 @@
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
: 0;
const docNonWs = docNonWhitespace();
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
resetSourceHistory(sourceContent);
lastSourceMode = true;
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
@@ -5780,20 +5789,20 @@
};
if (isMobile) {
// Mobile: editor stays in DOM, just update its content
const content = srcText || ($activeNote?.content ?? '');
const content = srcText;
if (editor) {
ignoreNextUpdate = true;
editor.commands.setContent(markdownToHtml(content));
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
tick().then(restoreRichCaret);
}
} else {
// Desktop: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor();
const content = srcText || ($activeNote?.content ?? '');
const content = srcText;
tick().then(() => {
if (editorElement && !editor) {
createEditor(content);
createEditor(restoreTitleH1(content));
restoreRichCaret();
}
});
@@ -5926,7 +5935,7 @@
const oldPath = $activeNotePath;
$activeNote.meta.title = newTitle;
// Update stripped title so restoreTitleH1 uses the new title
if (titleWasStripped) strippedTitle = newTitle;
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
$editorDirty = true;
// Force save current editor content before renaming so disk is up-to-date
await forceSave();
@@ -6222,7 +6231,7 @@
></textarea>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div>
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} onkeydown={(e) => { if (e.key === 'Escape') closeLinkContextMenu(); }}></div>
{:else}
<!-- Desktop: conditional rendering with line numbers -->
{#if $sourceMode}
@@ -6326,7 +6335,7 @@
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} onkeydown={(e) => { if (e.key === 'Escape') closeLinkContextMenu(); }} oncontextmenu={handleEditorContextMenu}></div>
{/if}
{/if}
</div>
@@ -6342,7 +6351,7 @@
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button class="history-close" onclick={() => { showHistory = false; historyPreview = null; historySelected = null; }}>
<button class="history-close" onclick={() => { showHistory = false; historyPreview = null; historySelected = null; }} aria-label="Close version history">
<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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -6382,7 +6391,7 @@
<div class="outline-panel" style="width: {$outlineWidth}px">
<div class="outline-header">
<h3>Outline</h3>
<button class="outline-close" onclick={() => { showOutline = false; }}>
<button class="outline-close" onclick={() => { showOutline = false; }} aria-label="Close outline">
<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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -6409,7 +6418,7 @@
<div class="info-panel">
<div class="info-panel-header">
<span class="info-panel-title">Note Info</span>
<button class="info-close-btn" onclick={() => showInfo = false}>
<button class="info-close-btn" onclick={() => showInfo = false} aria-label="Close note info">
<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
@@ -6505,7 +6514,7 @@
{#if editorReady && !$sourceMode && !$viewerNote}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="editor-formatting-bar" style={isMobile ? `${keyboardHeight > 0 ? `bottom: ${keyboardHeight}px;` : ''}${anyDropdownOpen ? 'overflow: visible;' : ''}` : ''} onclick={() => { headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }}>
<div class="editor-formatting-bar" style={isMobile ? `${keyboardHeight > 0 ? `bottom: ${keyboardHeight}px;` : ''}${anyDropdownOpen ? 'overflow: visible;' : ''}` : ''} onclick={handleFormattingBarClick} onkeydown={(e) => { if (e.key === 'Escape') closeFormattingDropdowns(); }}>
{#if isMobile}
<!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ -->
@@ -6515,8 +6524,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
</button>
{#if insertDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="fmt-dropdown insert-dropdown">
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
Image
@@ -6567,16 +6575,15 @@
<!-- Heading dropdown -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
</button>
{#if headingDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
<div class="fmt-dropdown">
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
</div>
{/if}
</div>
@@ -6584,36 +6591,36 @@
<div class="fmt-sep"></div>
<!-- Bold / Italic / Underline / Strike -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Link -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link">
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title="Link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title="Bullet List">
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title="Bullet List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title="Task List">
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title="Task List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
</button>
@@ -6667,7 +6674,7 @@
<div class="fmt-sep"></div>
<!-- Highlight -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
</button>
@@ -6706,8 +6713,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
</button>
{#if insertDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="fmt-dropdown insert-dropdown">
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
Image
@@ -6736,17 +6742,16 @@
<!-- Heading dropdown -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
</button>
{#if headingDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
<button class:active={(editorState, editor.isActive('heading', { level: 4 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
<div class="fmt-dropdown">
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
</div>
{/if}
</div>
@@ -6754,16 +6759,16 @@
<div class="fmt-sep"></div>
<!-- Text formatting -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
</button>
@@ -6771,14 +6776,13 @@
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); colorDropdown = !colorDropdown; headingDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Text Color">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('textStyle').color || 'var(--accent)'}"></span>
<span class="color-indicator" style="background: {getEditorAttributes('textStyle').color || 'var(--accent)'}"></span>
</button>
{#if colorDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="fmt-dropdown color-grid-dropdown">
{#each textColors as color}
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
{#if (color.value === '' && !editor.getAttributes('textStyle').color) || editor.getAttributes('textStyle').color === color.value}
{#if (color.value === '' && !getEditorAttributes('textStyle').color) || getEditorAttributes('textStyle').color === color.value}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</button>
@@ -6790,20 +6794,20 @@
<div class="fmt-sep"></div>
<!-- Link -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
</button>
@@ -6820,25 +6824,25 @@
<div class="fmt-sep"></div>
<!-- Code & Code Block -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
<button class="fmt-btn" class:active={isEditorActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
<button class="fmt-btn" class:active={isEditorActive('codeBlock')} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
</button>
<!-- Blockquote -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
<button class="fmt-btn" class:active={isEditorActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
</button>
<!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
<button class="fmt-btn" class:active={isEditorActive('details')} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
</button>
<!-- Callout -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('callout'))} onclick={() => insertCallout('note')} title="Callout">
<button class="fmt-btn" class:active={isEditorActive('callout')} onclick={() => insertCallout('note')} title="Callout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
</button>
@@ -6848,18 +6852,18 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
</button>
{#if tablePickerOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown table-picker-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="fmt-dropdown table-picker-dropdown">
<div class="table-picker-grid">
{#each Array(8) as _, r}
{#each Array(10) as _, c}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
<button
type="button"
class="table-picker-cell"
class:active={r < tablePickerHover.rows && c < tablePickerHover.cols}
aria-label={`Insert ${r + 1} by ${c + 1} table`}
onmouseenter={() => tablePickerHover = { rows: r + 1, cols: c + 1 }}
onclick={() => insertTable(r + 1, c + 1)}
></div>
></button>
{/each}
{/each}
</div>
@@ -6879,22 +6883,21 @@
<!-- Highlight -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
<span class="color-indicator" style="background: {getEditorAttributes('highlight').color || 'var(--accent)'}"></span>
</button>
{#if highlightDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="fmt-dropdown color-grid-dropdown">
{#each highlightColors as color}
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
{#if editor.isActive('highlight', { color: color.value })}
{#if isEditorActive('highlight', { color: color.value })}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</button>
{/each}
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
{#if !editor.isActive('highlight')}
{#if !isEditorActive('highlight')}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
@@ -6905,10 +6908,10 @@
</div>
<!-- Subscript & Superscript -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('subscript'))} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
<button class="fmt-btn" class:active={isEditorActive('subscript')} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 00-2.62-.44c-.42.24-.74.62-.9 1.07"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('superscript'))} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
<button class="fmt-btn" class:active={isEditorActive('superscript')} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 00-2.617-.436c-.42.239-.738.614-.899 1.06"/></svg>
</button>
@@ -6917,32 +6920,31 @@
<!-- Text Alignment -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); alignDropdown = !alignDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; insertDropdown = false; }} title="Text Alignment">
{#if (editorState, editor.isActive({ textAlign: 'center' }))}
{#if isEditorActive({ textAlign: 'center' })}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
{:else if (editorState, editor.isActive({ textAlign: 'right' }))}
{:else if isEditorActive({ textAlign: 'right' })}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
{:else if (editorState, editor.isActive({ textAlign: 'justify' }))}
{:else if isEditorActive({ textAlign: 'justify' })}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></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"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
{/if}
</button>
{#if alignDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
<button class:active={(editorState, editor.isActive({ textAlign: 'left' }))} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
<div class="fmt-dropdown align-dropdown">
<button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
Left
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'center' }))} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
<button class:active={isEditorActive({ textAlign: 'center' })} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
Center
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'right' }))} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
<button class:active={isEditorActive({ textAlign: 'right' })} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
Right
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'justify' }))} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
<button class:active={isEditorActive({ textAlign: 'justify' })} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
Justify
</button>
@@ -7022,9 +7024,8 @@
{#if linkContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-context-overlay" onclick={closeLinkContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="link-context-overlay" onclick={(e) => closeFromOverlay(e, closeLinkContextMenu)} onkeydown={(e) => closeOnEscape(e, closeLinkContextMenu)}>
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px">
<div class="link-context-url">{linkContextMenu.href}</div>
<button onclick={linkMenuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -7069,9 +7070,8 @@
{#if textContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="text-ctx-overlay" onclick={closeTextContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="text-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTextContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTextContextMenu)}>
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px">
<button onclick={ctxCut}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
Cut
@@ -7103,12 +7103,12 @@
</button>
{#if ctxHeadingSubmenu}
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 4 }))} onclick={() => ctxSetHeading(4)}>Heading 4</button>
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => ctxSetHeading(1)}>Heading 1</button>
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => ctxSetHeading(2)}>Heading 2</button>
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => ctxSetHeading(3)}>Heading 3</button>
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
<div class="text-ctx-sep"></div>
<button class:active={(editorState, editor?.isActive('paragraph'))} onclick={ctxSetParagraph}>Paragraph</button>
<button class:active={isEditorActive('paragraph')} onclick={ctxSetParagraph}>Paragraph</button>
</div>
{/if}
</div>
@@ -7199,9 +7199,8 @@
{#if tableContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
<div class="table-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTableContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTableContextMenu)}>
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;">
<button onclick={tblAddRowBefore}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg>
Add Row Above
@@ -7275,9 +7274,8 @@
{#if imageToolbar}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
<div class="img-toolbar-overlay" onclick={(e) => closeFromOverlay(e, () => (imageToolbar = null))} onkeydown={(e) => closeOnEscape(e, () => (imageToolbar = null))}>
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px">
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
@@ -7323,8 +7321,8 @@
{#if mathModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="math-modal-overlay" onclick={cancelMathModal}>
<div class="math-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Math editor">
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelMathModal)} onkeydown={(e) => closeOnEscape(e, cancelMathModal)}>
<div class="math-modal" role="dialog" aria-modal="true" aria-label="Math editor" tabindex="-1">
<div class="math-modal-header">
<span>{mathModal.editPos !== null ? 'Edit' : 'Insert'} {mathModal.kind === 'block' ? 'Math Block' : 'Inline Math'}</span>
<button type="button" class="math-modal-close" onclick={cancelMathModal} aria-label="Close">
@@ -7339,7 +7337,7 @@
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); }
if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); }
}}
autofocus
use:autofocus
></textarea>
<div class="math-modal-preview">
{#if mathModal.tex.trim()}
@@ -7363,8 +7361,8 @@
{#if secretModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="math-modal-overlay" onclick={cancelSecretModal}>
<div class="math-modal secret-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Encrypted secret editor">
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelSecretModal)} onkeydown={(e) => closeOnEscape(e, cancelSecretModal)}>
<div class="math-modal secret-modal" role="dialog" aria-modal="true" aria-label="Encrypted secret editor" tabindex="-1">
<div class="math-modal-header">
<span>Insert Secret</span>
<button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close">
@@ -7431,8 +7429,8 @@
{#if viewerImportPickerOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="viewer-import-overlay" onclick={() => (viewerImportPickerOpen = false)}>
<div class="viewer-import-picker" onclick={(e) => e.stopPropagation()} role="dialog">
<div class="viewer-import-overlay" onclick={(e) => closeFromOverlay(e, () => (viewerImportPickerOpen = false))} onkeydown={(e) => closeOnEscape(e, () => (viewerImportPickerOpen = false))}>
<div class="viewer-import-picker" role="dialog" aria-modal="true" aria-label="Import image to folder" tabindex="-1">
<div class="viewer-import-header">
<span>Import to folder</span>
<button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close">
@@ -7457,11 +7455,8 @@
{#if tagMenu && $activeNote}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="tag-menu-overlay" onclick={() => (tagMenu = null)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="tag-menu-overlay" onclick={(e) => closeFromOverlay(e, () => (tagMenu = null))} onkeydown={(e) => closeOnEscape(e, () => (tagMenu = null))}>
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
{#if $activeNote.meta.tags.length > 0}
<div class="tag-menu-list">
{#each $activeNote.meta.tags as tag}
@@ -7486,9 +7481,8 @@
{#if codeLangDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
<div class="code-lang-overlay" onclick={(e) => closeFromOverlay(e, closeCodeLangDropdown)} onkeydown={(e) => closeOnEscape(e, closeCodeLangDropdown)}>
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px">
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => {
if (e.key === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
if (e.key === 'Escape') closeCodeLangDropdown();
@@ -7516,9 +7510,8 @@
{#if slashMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={closeSlashMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeSlashMenu)} onkeydown={(e) => closeOnEscape(e, closeSlashMenu)}>
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px">
{#if slashTablePicker}
<div class="slash-table-picker">
<div class="slash-table-picker-grid">
@@ -7580,9 +7573,8 @@
{#if taskMetaMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={closeTaskMetaMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeTaskMetaMenu)} onkeydown={(e) => closeOnEscape(e, closeTaskMetaMenu)}>
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px">
{#if taskMetaFiltered.length === 0}
<div class="slash-menu-empty">No match</div>
{:else}
@@ -7604,13 +7596,12 @@
{#if taskDuePicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={() => { taskDuePicker = null; editor?.commands.focus(); }}>
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, () => { taskDuePicker = null; editor?.commands.focus(); })} onkeydown={(e) => closeOnEscape(e, () => { taskDuePicker = null; editor?.commands.focus(); })}>
<input
type="date"
class="task-due-input"
bind:this={taskDueInputEl}
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
onclick={(e) => e.stopPropagation()}
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
/>
@@ -7619,9 +7610,8 @@
{#if wikiLinkMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, closeWikiLinkMenu)} onkeydown={(e) => closeOnEscape(e, closeWikiLinkMenu)}>
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px">
{#if wikiLinkFiltered.length === 0}
<div class="wiki-link-empty">
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
@@ -7650,9 +7640,8 @@
{#if wikiLinkNavDisambig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, () => wikiLinkNavDisambig = null)} onkeydown={(e) => closeOnEscape(e, () => wikiLinkNavDisambig = null)}>
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px">
<div class="wiki-link-disambig-header">Multiple notes found - choose one:</div>
{#each wikiLinkNavDisambig.entries as entry, i}
<button
@@ -7674,9 +7663,8 @@
{#if aiMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`} onclick={(e) => e.stopPropagation()}>
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={(e) => closeFromOverlay(e, closeAiMenu)} onkeydown={(e) => closeOnEscape(e, closeAiMenu)}>
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`}>
{#if aiResult !== null || aiLoading}
<!-- Result view -->
<div class="ai-result-header">
@@ -7688,7 +7676,7 @@
AI Result
{/if}
</span>
<button class="ai-result-close" onclick={closeAiMenu}>
<button class="ai-result-close" onclick={closeAiMenu} aria-label="Close AI menu">
<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="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
@@ -7712,7 +7700,7 @@
{:else if aiShowCustom}
<!-- Custom prompt input -->
<div class="ai-custom-header">
<button class="ai-back-btn" onclick={() => aiShowCustom = false}>
<button class="ai-back-btn" onclick={() => aiShowCustom = false} aria-label="Back to AI actions">
<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="15 18 9 12 15 6"/></svg>
</button>
<span>Custom Prompt</span>
@@ -7733,7 +7721,7 @@
{:else if aiTranslateMenu}
<!-- Translate submenu -->
<div class="ai-custom-header">
<button class="ai-back-btn" onclick={() => aiTranslateMenu = false}>
<button class="ai-back-btn" onclick={() => aiTranslateMenu = false} aria-label="Back to AI actions">
<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="15 18 9 12 15 6"/></svg>
</button>
<span>Translate to</span>
@@ -7815,9 +7803,8 @@
{#if linkModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-modal-overlay" onclick={linkModalCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-modal" onclick={(e) => e.stopPropagation()}>
<div class="link-modal-overlay" onclick={(e) => closeFromOverlay(e, linkModalCancel)} onkeydown={(e) => closeOnEscape(e, linkModalCancel)}>
<div class="link-modal" role="dialog" aria-modal="true" aria-label="Insert link" tabindex="-1">
<div class="link-modal-header">
<svg width="28" height="28" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" />
@@ -11240,11 +11227,11 @@
gap: 2px;
}
.toolbar-actions.mobile {
.editor-container.mobile .toolbar-actions {
gap: 4px;
}
.toolbar-actions.mobile .icon-btn {
.editor-container.mobile .toolbar-actions .icon-btn {
min-width: 32px;
min-height: 32px;
display: flex;
@@ -11252,8 +11239,8 @@
justify-content: center;
}
.toolbar-actions.mobile .save-indicator,
.toolbar-actions.mobile .readonly-indicator {
.editor-container.mobile .toolbar-actions .save-indicator,
.editor-container.mobile .toolbar-actions .readonly-indicator {
font-size: 12px;
}
+4 -4
View File
@@ -20,12 +20,12 @@
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
interface LabelRect { x: number; y: number; w: number; h: number; }
let nodes: GraphNode[] = [];
let edges: GraphEdge[] = [];
let nodes = $state.raw<GraphNode[]>([]);
let edges = $state.raw<GraphEdge[]>([]);
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let nodeDegree: number[] = [];
let searchMatchSet: Set<number> | null = null;
let searchMatchSet = $state.raw<Set<number> | null>(null);
let folderColorMap = new Map<string, string>();
let pan = { x: 0, y: 0 };
@@ -40,7 +40,7 @@
let hoveredNeighborSet: Set<number> = new Set();
let hoveredEdgeSet: Set<number> = new Set();
let glowPhase = 0;
let activeNodeIdx = -1;
let activeNodeIdx = $state(-1);
let navigatedFromGraph = false;
// Combined animation loop
+13 -3
View File
@@ -91,6 +91,17 @@
activeTab = isMobile ? 'about' : 'shortcuts';
}
function closeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
}
function openLink(url: string) {
openUrl(url).catch(console.error);
}
@@ -100,9 +111,8 @@
{#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<div class="info-panel">
<div class="info-header">
<h2>Info</h2>
<button class="close-btn" onclick={close} aria-label="Close info">
+26 -39
View File
@@ -37,12 +37,12 @@
reorderQuickAccess,
moveNote,
getAllTags,
createDailyNote
createDailyNote,
revealFile
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
@@ -966,11 +966,21 @@
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
}
function handleWindowClick() {
function handleWindowClick(event: MouseEvent) {
const target = event.target as Element | null;
if (target?.closest('.context-menu, .sort-menu')) return;
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
if (sortMenu) sortMenu = null;
}
function closeBatchMoveOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchMovePicker = false;
}
function closeBatchTagOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchTagEdit = false;
}
function openSortMenu(e: MouseEvent) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -1076,12 +1086,11 @@
{#if batchMovePicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
<div class="batch-move-header">
<span>Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false}>
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -1105,12 +1114,11 @@
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -1408,7 +1416,7 @@
</div>
{#if contextMenu}
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu -->
{#if $viewMode === 'trash'}
@@ -1559,7 +1567,7 @@
Open in New Window
</button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<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 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
@@ -1611,8 +1619,7 @@
{/if}
{#if sortMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
<div class="sort-menu-title">Sort by</div>
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -2335,13 +2342,13 @@
border-radius: 8px;
}
.note-list.mobile .sort-menu {
.sort-menu.mobile {
min-width: 220px;
border-radius: 12px;
padding: 6px;
}
.note-list.mobile .sort-menu button {
.sort-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
@@ -2365,26 +2372,6 @@
min-height: 44px;
}
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
+8 -8
View File
@@ -30,14 +30,14 @@
{#if appWindow && !maximized}
<div class="resize-layer" aria-hidden="true">
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
<div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
</div>
{/if}
+9 -4
View File
@@ -44,10 +44,15 @@
if (item) item.scrollIntoView({ block: 'nearest' });
}
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showSearch = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
$showSearch = false;
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
scrollToSelected();
@@ -128,7 +133,7 @@
{#if $showSearch}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}>
<div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="search-input-wrapper">
+93 -44
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
@@ -521,6 +521,17 @@
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
];
// Keep the default themes pinned at the top and sort the rest alphabetically.
const priorityThemeIds = ['system', 'light', 'dark'];
themePresets.sort((a, b) => {
const pa = priorityThemeIds.indexOf(a.id);
const pb = priorityThemeIds.indexOf(b.id);
if (pa !== -1 && pb !== -1) return pa - pb;
if (pa !== -1) return -1;
if (pb !== -1) return 1;
return a.label.localeCompare(b.label);
});
const accentPresets = [
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
@@ -583,6 +594,7 @@
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14);
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
@@ -744,6 +756,10 @@
closeCustomThemeEditor();
}
function cancelCustomThemeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) cancelCustomThemeEditor();
}
async function selectCustomTheme(ct: CustomTheme) {
$theme = ct.id;
setTheme(ct.id);
@@ -983,6 +999,13 @@
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
}
function toggleScrollToChangeFontSize() {
scrollToChangeFontSize = !scrollToChangeFontSize;
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
setScrollToChangeFontSize(scrollToChangeFontSize)
.catch((e) => console.error('Failed to save scroll font size setting:', e));
}
function applyFontSize(size: number) {
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
}
@@ -1052,6 +1075,23 @@
$showSettings = false;
}
function closeSettingsFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
if (restoreConfirm) restoreConfirm = null;
else if (customThemeEditorOpen) cancelCustomThemeEditor();
else close();
}
function dismissRestoreConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) restoreConfirm = null;
}
// Apply saved settings on mount
$effect(() => {
const savedAccent = $appConfig?.accent_color;
@@ -1068,6 +1108,7 @@
}
}
}
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
const savedSize = $appConfig?.font_size;
if (savedSize) {
activeFontSize = savedSize;
@@ -1127,12 +1168,11 @@
{#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
<div class="settings-header">
<h2>Settings</h2>
<button class="close-btn" onclick={close}>
<h2 id="settings-title">Settings</h2>
<button class="close-btn" onclick={close} aria-label="Close settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -1214,7 +1254,7 @@
<span class="setting-name">Compact mode</span>
<span class="setting-desc">Show notes in a denser layout without preview</span>
</span>
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={compactNotes} role="switch" aria-checked={compactNotes} aria-label="Compact notes" onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1223,7 +1263,7 @@
<span class="setting-name">Show dates</span>
<span class="setting-desc">Show the date next to each note in the list</span>
</span>
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showNoteDates} role="switch" aria-checked={showNoteDates} aria-label="Show note dates" onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1231,12 +1271,12 @@
<div class="settings-section">
<h3>Notebooks</h3>
<label class="setting-toggle">
<div class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sort order</span>
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
</span>
</label>
</div>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
@@ -1249,31 +1289,31 @@
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">All Notes</span></span>
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showAllNotes} role="switch" aria-checked={showAllNotes} aria-label="Show All Notes" onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showQuickAccess} role="switch" aria-checked={showQuickAccess} aria-label="Show Quick Access" onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Tasks</span></span>
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showTasks} role="switch" aria-checked={showTasks} aria-label="Show Tasks" onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showDailyNotes} role="switch" aria-checked={showDailyNotes} aria-label="Show Daily Notes" onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Trash</span></span>
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showTrash} role="switch" aria-checked={showTrash} aria-label="Show Trash" onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1288,7 +1328,7 @@
<span class="setting-name">Show note switcher in title bar</span>
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
</span>
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1312,7 +1352,7 @@
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={restoreLastSession} role="switch" aria-checked={restoreLastSession} aria-label="Restore last session on launch" onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1368,7 +1408,7 @@
<span class="setting-name">GPU Acceleration</span>
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
</span>
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={gpuAcceleration} role="switch" aria-checked={gpuAcceleration} aria-label="GPU acceleration" onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1381,7 +1421,7 @@
<span class="setting-name">Start at system startup</span>
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
</span>
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={autostart} role="switch" aria-checked={autostart} aria-label="Start at system startup" onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1390,7 +1430,7 @@
<span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
</span>
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showTrayIcon} role="switch" aria-checked={showTrayIcon} aria-label="Show in system tray" onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1400,7 +1440,7 @@
<span class="setting-name">Close to tray</span>
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
</span>
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={closeToTray} role="switch" aria-checked={closeToTray} aria-label="Close to tray" onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1489,7 +1529,7 @@
<span class="setting-name">Hide title in note body</span>
<span class="setting-desc">Hide the first heading when it matches the note title</span>
</span>
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={hideTitleInBody} role="switch" aria-checked={hideTitleInBody} aria-label="Hide title in note body" onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1498,7 +1538,7 @@
<span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</span>
</span>
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
<button class="toggle-switch" class:on={showLinkArrows} role="switch" aria-checked={showLinkArrows} aria-label="Show link arrows" onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1508,7 +1548,7 @@
<span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</span>
</span>
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showLineNumbers} role="switch" aria-checked={showLineNumbers} aria-label="Show line numbers" onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1518,7 +1558,7 @@
<span class="setting-name">Open notes in View Mode</span>
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
</span>
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={defaultViewMode} role="switch" aria-checked={defaultViewMode} aria-label="Open notes in View Mode" onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1527,7 +1567,7 @@
<span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={newNotesInSourceMode} role="switch" aria-checked={newNotesInSourceMode} aria-label="Open new notes in source mode" onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1541,7 +1581,7 @@
<span class="setting-name">Enable wiki links</span>
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
</span>
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={enableWikiLinks} role="switch" aria-checked={enableWikiLinks} aria-label="Enable wiki links" onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1556,7 +1596,7 @@
<span class="setting-name">Inline PDF preview</span>
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
</span>
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={pdfPreview} role="switch" aria-checked={pdfPreview} aria-label="Inline PDF preview" onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -1793,24 +1833,23 @@
<!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={handleEscape}>
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
<div class="custom-theme-modal-header">
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
<button class="close-btn" onclick={cancelCustomThemeEditor}>
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
<button class="close-btn" onclick={cancelCustomThemeEditor} aria-label="Close theme editor">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="custom-theme-modal-body">
<div class="ct-field">
<label class="ct-label">Theme Name</label>
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
<label class="ct-label" for="custom-theme-name">Theme Name</label>
<input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
</div>
<div class="ct-field">
<label class="ct-label">Mode</label>
<span class="ct-label">Mode</span>
<div class="setting-options">
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
@@ -1900,6 +1939,17 @@
</button>
{/each}
</div>
{#if !isMobile}
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Scroll to change font size</span>
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
</span>
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
<span class="toggle-knob"></span>
</button>
</label>
{/if}
</div>
{#if !isMobile}
@@ -1977,7 +2027,7 @@
<label class="setting-toggle">
<span>Enable automatic backup</span>
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} role="switch" aria-checked={$appConfig?.backup_enabled ?? false} aria-label="Enable automatic backup" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -2005,7 +2055,7 @@
<span class="setting-name">Include attachments</span>
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
</span>
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} role="switch" aria-checked={$appConfig?.backup_include_attachments ?? false} aria-label="Include attachments in backups" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
<span class="toggle-knob"></span>
</button>
</label>
@@ -2091,10 +2141,9 @@
{#if restoreConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}>
<h4>Restore Backup?</h4>
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={handleEscape}>
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
<h4 id="restore-confirm-title">Restore Backup?</h4>
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
<div class="restore-confirm-actions">
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
@@ -2443,14 +2492,14 @@
<span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync shortly after you edit a note.</span>
</span>
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
<button class="toggle-switch" class:on={syncOnChange} role="switch" aria-checked={syncOnChange} aria-label="Sync when a note changes" onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Sync once when the app starts.</span>
</span>
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
<button class="toggle-switch" class:on={syncOnOpen} role="switch" aria-checked={syncOnOpen} aria-label="Sync when the vault opens" onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
</div>
{/if}
+13 -9
View File
@@ -335,6 +335,10 @@
}
}
function dismissDeleteConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) deleteConfirm = null;
}
async function confirmDelete(nb: NotebookEntry) {
deleteConfirm = null;
try {
@@ -696,7 +700,7 @@
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
<div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -717,7 +721,7 @@
</div>
{/if}
{#if !$sidebarCollapsed}
{#if isMobile || !$sidebarCollapsed}
{#if anyNavItem}
<nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false}
@@ -1029,10 +1033,9 @@
{#if deleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
<div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
<h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
@@ -1071,7 +1074,9 @@
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path}
onclick={() => selectNotebook(nb)}
onclick={(e) => {
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
}}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
@@ -1153,12 +1158,11 @@
{/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
+4 -2
View File
@@ -214,19 +214,21 @@
{/if}
{#if isAndroid}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-selector" role="group" aria-labelledby="storage-location-label">
<span id="storage-location-label" class="location-label">Location</span>
<div class="location-options">
{#each storageLocations as loc}
<button
class="location-option"
class:active={selectedLocation === loc.label}
aria-pressed={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label}
>{loc.label}</button>
{/each}
<button
class="location-option"
class:active={selectedLocation === 'Custom'}
aria-pressed={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'}
>Custom</button>
</div>
+46
View File
@@ -0,0 +1,46 @@
export type HiddenTitleHeading = {
headingPrefix: string;
title: string;
};
type TitleHeadingResult = {
markdown: string;
hiddenTitle: HiddenTitleHeading | null;
};
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
}
export function stripTitleHeading(
markdown: string,
title: string | undefined,
hideTitle: boolean,
): TitleHeadingResult {
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
lines.splice(i, 1);
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
return { markdown: lines.join('\n'), hiddenTitle };
}
break;
}
return { markdown, hiddenTitle: null };
}
export function restoreTitleHeading(
markdown: string,
hiddenTitle: HiddenTitleHeading | null,
): string {
if (!hiddenTitle) return markdown;
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state';
export type WikiLinkAttributes = {
title: string;
path: string;
aliased: boolean;
};
export function replaceWithWikiLink(
state: EditorState,
from: number,
to: number,
text: string,
attrs: WikiLinkAttributes
): Transaction {
const wikiLinkMark = state.schema.marks.wikiLink.create(attrs);
const textNode = state.schema.text(text, [wikiLinkMark]);
const transaction = state.tr.replaceWith(from, to, textNode);
transaction.setSelection(TextSelection.create(transaction.doc, from + text.length));
transaction.setStoredMarks([]);
return transaction;
}
+1
View File
@@ -92,6 +92,7 @@ export interface AppConfig {
system_dark_theme: string;
accent_color: string | null;
font_size: number | null;
scroll_to_change_font_size: boolean;
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const { getWheelFontSizeAction } = await import(
new URL('./editor-zoom.ts', import.meta.url)
);
test('blocks modified wheel input when scroll font sizing is disabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, false),
'block'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, false),
'block'
);
});
test('changes font size from modified wheel input when enabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, true),
'increase'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, true),
'decrease'
);
});
test('ignores unmodified wheel input', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: false, deltaY: -1 }, false),
'ignore'
);
});
+16
View File
@@ -0,0 +1,16 @@
export type WheelFontSizeAction = 'ignore' | 'block' | 'increase' | 'decrease';
interface WheelModifierInput {
ctrlKey: boolean;
metaKey: boolean;
deltaY: number;
}
export function getWheelFontSizeAction(
event: WheelModifierInput,
scrollToChangeFontSize: boolean,
): WheelFontSizeAction {
if (!event.ctrlKey && !event.metaKey) return 'ignore';
if (!scrollToChangeFontSize) return 'block';
return event.deltaY < 0 ? 'increase' : 'decrease';
}
+13 -2
View File
@@ -3,6 +3,7 @@
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -16,6 +17,7 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null;
let removeScrollFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false;
@@ -121,10 +123,15 @@
};
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
const action = getWheelFontSizeAction(
event,
$appConfig?.scroll_to_change_font_size ?? true
);
if (action === 'ignore') return;
event.preventDefault();
event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
if (action === 'increase') zoomEditor(1);
else if (action === 'decrease') zoomEditor(-1);
};
window.addEventListener('keydown', handleKeydown);
@@ -153,6 +160,9 @@
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
});
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
});
$theme = config.theme || 'system';
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
@@ -287,6 +297,7 @@
onDestroy(() => {
removeEditorZoomShortcuts?.();
removeFontSizeListener?.();
removeScrollFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer);
});
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createContext, Script } from 'node:vm';
import { compile, parse } from 'svelte/compiler';
import { get, writable } from 'svelte/store';
const source = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
const ast = parse(source, { modern: true });
function elementWithClass(nodes, className) {
return nodes.find((node) => node.attributes?.some((attribute) =>
attribute.name === 'class' && attribute.value[0]?.data === className
));
}
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
const toggle = elementWithClass(
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
'collapse-btn'
);
function expressionScript(expression) {
assert.ok(expression, 'expected a component expression');
return new Script(`(${source.slice(expression.start, expression.end)})`);
}
// Execute the actual template conditions, not a copy of the collapse logic.
const expressions = {
contentVisible: expressionScript(content.test),
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
).expression),
toggleVisible: expressionScript(header.test)
};
const toggleScript = expressionScript(
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
);
function sidebarContext(isMobile, stored) {
const sidebarCollapsed = writable(stored);
const context = createContext({
isMobile,
get $sidebarCollapsed() { return get(sidebarCollapsed); },
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
});
return { context, sidebarCollapsed };
}
function evaluate(context) {
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
[name, script.runInContext(context)]
));
}
const scenarios = [
{
platform: 'desktop', isMobile: false,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
},
{
platform: 'mobile', isMobile: true,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
}
];
for (const scenario of scenarios) {
for (const stored of [false, true]) {
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
});
}
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
assert.deepEqual(evaluate(context), scenario.expanded);
sidebarCollapsed.set(true);
assert.deepEqual(evaluate(context), scenario.collapsed);
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
sidebarCollapsed.set(false);
assert.deepEqual(evaluate(context), scenario.expanded);
assert.equal(get(sidebarCollapsed), false);
});
}
test('desktop collapse toggle still collapses and expands the sidebar', () => {
const { context, sidebarCollapsed } = sidebarContext(false, false);
const toggleSidebar = toggleScript.runInContext(context);
toggleSidebar();
assert.equal(get(sidebarCollapsed), true);
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
toggleSidebar();
assert.equal(get(sidebarCollapsed), false);
assert.deepEqual(evaluate(context), scenarios[0].expanded);
});
test('Sidebar compiles for the client', () => {
assert.doesNotThrow(() => compile(source, {
filename: 'src/lib/components/Sidebar.svelte',
generate: 'client'
}));
});
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/paths.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'paths.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const {
assetSourceToMarkdown,
assetUrlToLocalPath,
normalizeLocalAssetPath,
resolvePathFromFile,
resolveVaultFilePath
} = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
test('resolves note links relative to Windows note paths', () => {
assert.equal(
resolvePathFromFile('C:\\Vault\\Current.md', './Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('C:\\Vault\\Folder\\Current.md', '../Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('\\\\server\\share\\Folder\\Current.md', '../Target.md'),
'//server/share/Target.md'
);
});
test('preserves Linux relative note-link resolution', () => {
assert.equal(
resolvePathFromFile('/vault/folder/Current.md', '../Target.md'),
'/vault/Target.md'
);
});
test('converts Windows asset URLs back to portable attachment paths', () => {
assert.equal(
normalizeLocalAssetPath('/C:\\Users\\user\\Vault\\image.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetSourceToMarkdown(
'http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5C.helixnotes%5Cattachments%5Cimage.png',
'C:\\Users\\user\\Vault\\Note.md',
'C:\\Users\\user\\Vault'
),
'.helixnotes/attachments/image.png'
);
});
test('preserves portable asset paths on Linux and macOS', () => {
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2Fhome%2Fuser%2FVault%2F.helixnotes%2Fattachments%2Fimage.png',
'/home/user/Vault/Note.md',
'/home/user/Vault'
),
'.helixnotes/attachments/image.png'
);
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2FUsers%2Fuser%2FVault%2Fassets%2Fimage.png',
'/Users/user/Vault/notes/Note.md',
'/Users/user/Vault'
),
'../assets/image.png'
);
assert.equal(
assetSourceToMarkdown('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'../assets/image.png'
);
});
test('decodes local asset URLs without damaging platform roots', () => {
assert.equal(
assetUrlToLocalPath('http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5Cimage.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('asset://localhost/%2Fhome%2Fuser%2FVault%2Fimage.png'),
'/home/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('http://asset.localhost/%5C%5Cserver%5Cshare%5CVault%5Cimage.png'),
'//server/share/Vault/image.png'
);
});
test('resolves vault files consistently across platforms', () => {
assert.equal(
resolveVaultFilePath('../assets/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('.helixnotes/attachments/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/.helixnotes/attachments/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'/vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '\\\\server\\share\\Vault\\notes\\Note.md', '\\\\server\\share\\Vault'),
'//server/share/Vault/assets/image.png'
);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = await readFile(
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
'utf8'
);
const initialization = source.match(
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
);
assert.ok(initialization, 'theme presets initialization was not found');
// Evaluate only the trusted repository declaration and its initialization code.
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
test('pins System, Light, and Dark in that exact order', () => {
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
{ id: 'system', label: 'System' },
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' }
]);
});
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
const labels = presets.slice(3).map((preset) => preset.label);
assert.ok(labels.includes('Rosé Pine'));
assert.deepEqual(labels, [...labels].sort());
});
test('preserves every theme ID, label, and color without duplicates', () => {
assert.equal(presets.length, unsorted.length);
for (const themes of [unsorted, presets]) {
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
}
assert.deepEqual(
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
);
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const { restoreTitleHeading, stripTitleHeading } = await import(
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
);
const editorSource = await readFile(
new URL('../src/lib/components/Editor.svelte', import.meta.url),
'utf8'
);
test('rich to source transition strips the restored title before display', () => {
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
assert.notEqual(start, -1, 'rich to source transition was not found');
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
assert.match(
editorSource.slice(start, end),
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
);
});
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
assert.notEqual(start, -1, 'source to rich transition was not found');
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
assert.match(
transition,
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
);
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
});
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
.map((match) => match[1].trim());
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
assert.doesNotMatch(transition, /srcText\s*\|\|/);
});
test('keeps a hidden title through source to rich to source and save', () => {
const persisted = '# Note title\n\n## Something else\n\nBody\n';
const body = '## Something else\n\nBody\n';
const initialSource = stripTitleHeading(persisted, 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const toggledSource = stripTitleHeading(
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
'Note title',
true
);
assert.equal(rich.markdown, body);
assert.equal(toggledSource.markdown, body);
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
});
test('keeps a title-only note visually empty and saves one title heading', () => {
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
assert.equal(initialSource.markdown, '');
assert.equal(rich.markdown, '');
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
});
test('replaces hidden title state when an unrelated note loads', () => {
const markdown = '## Something else\n\nOther body\n';
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
markdown,
hiddenTitle: null
});
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
});
test('leaves the title visible when title hiding is disabled', () => {
const persisted = '# Note title\n\n## Something else\n';
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
markdown: persisted,
hiddenTitle: null
});
});